Files
cameleer-server/docs/superpowers/plans/2026-04-14-sensitive-keys-server.md
hsiegeln 891abbfcfd
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m26s
CI / docker (push) Successful in 1m8s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
docs: add sensitive keys feature documentation
- CLAUDE.md: add SensitiveKeysConfig, SensitiveKeysRepository, SensitiveKeysMerger
  to core admin classes; add SensitiveKeysAdminController endpoint; add
  PostgresSensitiveKeysRepository; add sensitive keys convention; add admin page
  to UI structure
- Design spec and implementation plan for the feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:29:15 +02:00

47 KiB
Raw Blame History

Sensitive Keys Server-Side Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add server-side support for unified sensitive key masking — global admin baseline (enforced, additive-only), per-app additions, SSE push to agents, and admin UI.

Architecture: Global keys stored in server_config table (existing JSONB KV store). Per-app additions stored as part of ApplicationConfig JSONB. SensitiveKeysMerger computes global per-app for agent payloads. New admin endpoint follows existing ThresholdAdmin pattern. UI: new admin page + new section in existing AppConfigDetailPage.

Tech Stack: Java 17 (Spring Boot 3.4.3), PostgreSQL (JSONB), React + TypeScript (Vite), @cameleer/design-system, TanStack Query


Task 1: Core — SensitiveKeysConfig Record

Files:

  • Create: cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysConfig.java

  • Step 1: Create the config record

package com.cameleer3.server.core.admin;

import java.util.List;

public record SensitiveKeysConfig(List<String> keys) {

    public SensitiveKeysConfig {
        keys = keys != null ? List.copyOf(keys) : List.of();
    }
}
  • Step 2: Commit
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysConfig.java
git commit -m "feat: add SensitiveKeysConfig record"

Task 2: Core — SensitiveKeysRepository Interface

Files:

  • Create: cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysRepository.java

  • Step 1: Create the repository interface

Follow the ThresholdRepository pattern exactly.

package com.cameleer3.server.core.admin;

import java.util.Optional;

public interface SensitiveKeysRepository {
    Optional<SensitiveKeysConfig> find();
    void save(SensitiveKeysConfig config, String updatedBy);
}
  • Step 2: Commit
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysRepository.java
git commit -m "feat: add SensitiveKeysRepository interface"

Task 3: Core — SensitiveKeysMerger (TDD)

Files:

  • Create: cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysMerger.java

  • Create: cameleer3-server-core/src/test/java/com/cameleer3/server/core/admin/SensitiveKeysMergerTest.java

  • Step 1: Write failing tests

package com.cameleer3.server.core.admin;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class SensitiveKeysMergerTest {

    @Test
    void bothNull_returnsNull() {
        assertNull(SensitiveKeysMerger.merge(null, null));
    }

    @Test
    void globalOnly_returnsGlobal() {
        List<String> result = SensitiveKeysMerger.merge(
                List.of("Authorization", "Cookie"), null);
        assertEquals(List.of("Authorization", "Cookie"), result);
    }

    @Test
    void perAppOnly_returnsPerApp() {
        List<String> result = SensitiveKeysMerger.merge(
                null, List.of("X-Internal-*"));
        assertEquals(List.of("X-Internal-*"), result);
    }

    @Test
    void union_mergesWithoutDuplicates() {
        List<String> result = SensitiveKeysMerger.merge(
                List.of("Authorization", "Cookie"),
                List.of("Cookie", "X-Custom"));
        assertEquals(List.of("Authorization", "Cookie", "X-Custom"), result);
    }

    @Test
    void caseInsensitiveDedup_preservesFirstCasing() {
        List<String> result = SensitiveKeysMerger.merge(
                List.of("Authorization"),
                List.of("authorization", "AUTHORIZATION"));
        assertEquals(List.of("Authorization"), result);
    }

    @Test
    void emptyGlobal_returnsEmptyList() {
        // Explicit empty list = opt-out (agents mask nothing)
        List<String> result = SensitiveKeysMerger.merge(List.of(), null);
        assertEquals(List.of(), result);
    }

    @Test
    void emptyGlobalWithPerApp_returnsPerApp() {
        // Explicit empty global + per-app additions = just per-app
        List<String> result = SensitiveKeysMerger.merge(
                List.of(), List.of("X-Custom"));
        assertEquals(List.of("X-Custom"), result);
    }

    @Test
    void globPatterns_preserved() {
        List<String> result = SensitiveKeysMerger.merge(
                List.of("*password*", "*secret*"),
                List.of("X-Internal-*"));
        assertEquals(List.of("*password*", "*secret*", "X-Internal-*"), result);
    }
}
  • Step 2: Run tests to verify they fail

Run: mvn test -pl cameleer3-server-core -Dtest=SensitiveKeysMergerTest -Dsurefire.failIfNoSpecifiedTests=false Expected: compilation error — SensitiveKeysMerger does not exist

  • Step 3: Write implementation
package com.cameleer3.server.core.admin;

import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;

/**
 * Merges global (enforced) sensitive keys with per-app additions.
 * Union-only: per-app can add keys, never remove global keys.
 * Case-insensitive deduplication, preserves first-seen casing.
 */
public final class SensitiveKeysMerger {

    private SensitiveKeysMerger() {}

    /**
     * @param global  enforced global keys (null = not configured)
     * @param perApp  per-app additional keys (null = none)
     * @return merged list, or null if both inputs are null
     */
    public static List<String> merge(List<String> global, List<String> perApp) {
        if (global == null && perApp == null) {
            return null;
        }

        TreeSet<String> seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        List<String> result = new ArrayList<>();

        if (global != null) {
            for (String key : global) {
                if (seen.add(key)) {
                    result.add(key);
                }
            }
        }
        if (perApp != null) {
            for (String key : perApp) {
                if (seen.add(key)) {
                    result.add(key);
                }
            }
        }
        return result;
    }
}
  • Step 4: Run tests to verify they pass

Run: mvn test -pl cameleer3-server-core -Dtest=SensitiveKeysMergerTest Expected: all 8 tests PASS

  • Step 5: Commit
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysMerger.java
git add cameleer3-server-core/src/test/java/com/cameleer3/server/core/admin/SensitiveKeysMergerTest.java
git commit -m "feat: add SensitiveKeysMerger with case-insensitive union dedup"

Task 4: App — PostgresSensitiveKeysRepository

Files:

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresSensitiveKeysRepository.java

  • Step 1: Create the repository

Follow PostgresThresholdRepository pattern exactly.

package com.cameleer3.server.app.storage;

import com.cameleer3.server.core.admin.SensitiveKeysConfig;
import com.cameleer3.server.core.admin.SensitiveKeysRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class PostgresSensitiveKeysRepository implements SensitiveKeysRepository {

    private final JdbcTemplate jdbc;
    private final ObjectMapper objectMapper;

    public PostgresSensitiveKeysRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
        this.jdbc = jdbc;
        this.objectMapper = objectMapper;
    }

    @Override
    public Optional<SensitiveKeysConfig> find() {
        List<SensitiveKeysConfig> results = jdbc.query(
                "SELECT config_val FROM server_config WHERE config_key = 'sensitive_keys'",
                (rs, rowNum) -> {
                    String json = rs.getString("config_val");
                    try {
                        return objectMapper.readValue(json, SensitiveKeysConfig.class);
                    } catch (JsonProcessingException e) {
                        throw new RuntimeException("Failed to deserialize sensitive keys config", e);
                    }
                });
        return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
    }

    @Override
    public void save(SensitiveKeysConfig config, String updatedBy) {
        String json;
        try {
            json = objectMapper.writeValueAsString(config);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize sensitive keys config", e);
        }

        jdbc.update("""
                INSERT INTO server_config (config_key, config_val, updated_by, updated_at)
                VALUES ('sensitive_keys', ?::jsonb, ?, now())
                ON CONFLICT (config_key) DO UPDATE SET
                    config_val = EXCLUDED.config_val,
                    updated_by = EXCLUDED.updated_by,
                    updated_at = now()
                """,
                json, updatedBy);
    }
}
  • Step 2: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresSensitiveKeysRepository.java
git commit -m "feat: add PostgresSensitiveKeysRepository"

Task 5: App — SensitiveKeysAdminController

Files:

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java

  • Step 1: Create the request DTO

package com.cameleer3.server.app.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

import java.util.List;

@Schema(description = "Global sensitive keys configuration")
public record SensitiveKeysRequest(
        @NotNull
        @Schema(description = "List of key names or glob patterns to mask")
        List<String> keys
) {}
  • Step 2: Create the response DTO
package com.cameleer3.server.app.dto;

import java.util.List;

public record SensitiveKeysResponse(
        List<String> keys,
        CommandGroupResponse pushResult
) {}
  • Step 3: Create the controller
package com.cameleer3.server.app.controller;

import com.cameleer3.common.model.ApplicationConfig;
import com.cameleer3.server.app.dto.CommandGroupResponse;
import com.cameleer3.server.app.dto.SensitiveKeysRequest;
import com.cameleer3.server.app.dto.SensitiveKeysResponse;
import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.admin.SensitiveKeysConfig;
import com.cameleer3.server.core.admin.SensitiveKeysMerger;
import com.cameleer3.server.core.admin.SensitiveKeysRepository;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.agent.CommandReply;
import com.cameleer3.server.core.agent.CommandType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

@RestController
@RequestMapping("/api/v1/admin/sensitive-keys")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Sensitive Keys Admin", description = "Global sensitive key masking configuration (ADMIN only)")
public class SensitiveKeysAdminController {

    private static final Logger log = LoggerFactory.getLogger(SensitiveKeysAdminController.class);

    private final SensitiveKeysRepository sensitiveKeysRepository;
    private final PostgresApplicationConfigRepository configRepository;
    private final AgentRegistryService registryService;
    private final ObjectMapper objectMapper;
    private final AuditService auditService;

    public SensitiveKeysAdminController(SensitiveKeysRepository sensitiveKeysRepository,
                                         PostgresApplicationConfigRepository configRepository,
                                         AgentRegistryService registryService,
                                         ObjectMapper objectMapper,
                                         AuditService auditService) {
        this.sensitiveKeysRepository = sensitiveKeysRepository;
        this.configRepository = configRepository;
        this.registryService = registryService;
        this.objectMapper = objectMapper;
        this.auditService = auditService;
    }

    @GetMapping
    @Operation(summary = "Get global sensitive keys configuration")
    @ApiResponse(responseCode = "200", description = "Config returned")
    @ApiResponse(responseCode = "204", description = "Not configured")
    public ResponseEntity<SensitiveKeysConfig> getSensitiveKeys(HttpServletRequest httpRequest) {
        auditService.log("view_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys",
                null, AuditResult.SUCCESS, httpRequest);
        return sensitiveKeysRepository.find()
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.noContent().build());
    }

    @PutMapping
    @Operation(summary = "Update global sensitive keys",
            description = "Saves global keys. Set pushToAgents=true to push merged config to all LIVE agents.")
    @ApiResponse(responseCode = "200", description = "Config saved")
    public ResponseEntity<SensitiveKeysResponse> updateSensitiveKeys(
            @Valid @RequestBody SensitiveKeysRequest request,
            @RequestParam(defaultValue = "false") boolean pushToAgents,
            HttpServletRequest httpRequest) {

        SensitiveKeysConfig config = new SensitiveKeysConfig(request.keys());
        sensitiveKeysRepository.save(config, null);

        CommandGroupResponse pushResult = null;
        int appsPushed = 0;
        int totalAgents = 0;

        if (pushToAgents) {
            var fanOutResult = fanOutToAllAgents(config.keys());
            pushResult = fanOutResult.aggregated;
            appsPushed = fanOutResult.appCount;
            totalAgents = fanOutResult.aggregated.total();
        }

        auditService.log("update_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys",
                Map.of("keys", config.keys(),
                        "pushToAgents", pushToAgents,
                        "appsPushed", appsPushed,
                        "totalAgents", totalAgents),
                AuditResult.SUCCESS, httpRequest);

        log.info("Global sensitive keys updated ({} keys), pushToAgents={}, apps={}, agents={}",
                config.keys().size(), pushToAgents, appsPushed, totalAgents);

        return ResponseEntity.ok(new SensitiveKeysResponse(config.keys(), pushResult));
    }

    private record FanOutResult(CommandGroupResponse aggregated, int appCount) {}

    private FanOutResult fanOutToAllAgents(List<String> globalKeys) {
        // Collect distinct applications from stored configs + live agents
        Set<String> applications = new HashSet<>();
        for (ApplicationConfig cfg : configRepository.findAll()) {
            applications.add(cfg.getApplication());
        }
        for (AgentInfo agent : registryService.findAll()) {
            if (agent.state() == AgentState.LIVE) {
                applications.add(agent.applicationId());
            }
        }

        List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
        List<String> allTimedOut = new ArrayList<>();
        int totalAgents = 0;

        for (String application : applications) {
            ApplicationConfig appConfig = configRepository.findByApplication(application)
                    .orElse(null);
            List<String> perAppKeys = appConfig != null ? appConfig.getSensitiveKeys() : null;
            List<String> merged = SensitiveKeysMerger.merge(globalKeys, perAppKeys);

            // Build a config with the merged keys for the SSE payload
            ApplicationConfig pushConfig = appConfig != null ? appConfig : new ApplicationConfig();
            pushConfig.setApplication(application);
            pushConfig.setSensitiveKeys(merged);

            String payloadJson;
            try {
                payloadJson = objectMapper.writeValueAsString(pushConfig);
            } catch (JsonProcessingException e) {
                log.error("Failed to serialize config for {}", application, e);
                continue;
            }

            Map<String, CompletableFuture<CommandReply>> futures =
                    registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson);

            totalAgents += futures.size();

            long deadline = System.currentTimeMillis() + 10_000;
            for (var entry : futures.entrySet()) {
                long remaining = deadline - System.currentTimeMillis();
                if (remaining <= 0) {
                    allTimedOut.add(entry.getKey());
                    entry.getValue().cancel(false);
                    continue;
                }
                try {
                    CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS);
                    allResponses.add(new CommandGroupResponse.AgentResponse(
                            entry.getKey(), reply.status(), reply.message()));
                } catch (TimeoutException e) {
                    allTimedOut.add(entry.getKey());
                    entry.getValue().cancel(false);
                } catch (Exception e) {
                    allResponses.add(new CommandGroupResponse.AgentResponse(
                            entry.getKey(), "ERROR", e.getMessage()));
                }
            }
        }

        boolean allSuccess = allTimedOut.isEmpty() &&
                allResponses.stream().allMatch(r -> "SUCCESS".equals(r.status()));
        return new FanOutResult(
                new CommandGroupResponse(allSuccess, totalAgents, allResponses.size(), allResponses, allTimedOut),
                applications.size());
    }
}
  • Step 4: Verify compilation

Run: mvn clean compile -pl cameleer3-server-core,cameleer3-server-app Expected: BUILD SUCCESS

  • Step 5: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java
git commit -m "feat: add SensitiveKeysAdminController with fan-out support"

Task 6: Enhance ApplicationConfigController — Merge Global Keys

Files:

  • Modify: cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java

The GET endpoint must return globalSensitiveKeys and mergedSensitiveKeys so the UI can render read-only global pills. The PUT push must merge before sending to agents. Since ApplicationConfig lives in cameleer3-common, we cannot add fields to it directly. Instead, we wrap the response.

  • Step 1: Create AppConfigResponse DTO

Create cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AppConfigResponse.java:

package com.cameleer3.server.app.dto;

import com.cameleer3.common.model.ApplicationConfig;

import java.util.List;

/**
 * Wraps ApplicationConfig with additional server-computed fields for the UI.
 */
public record AppConfigResponse(
        ApplicationConfig config,
        List<String> globalSensitiveKeys,
        List<String> mergedSensitiveKeys
) {}
  • Step 2: Modify ApplicationConfigController

Add SensitiveKeysRepository to the constructor. Modify getConfig() to return AppConfigResponse. Modify pushConfigToAgents() to merge keys before sending.

In ApplicationConfigController.java:

Constructor — add parameter:

private final SensitiveKeysRepository sensitiveKeysRepository;

public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
                                   AgentRegistryService registryService,
                                   ObjectMapper objectMapper,
                                   AuditService auditService,
                                   DiagramStore diagramStore,
                                   SensitiveKeysRepository sensitiveKeysRepository) {
    this.configRepository = configRepository;
    this.registryService = registryService;
    this.objectMapper = objectMapper;
    this.auditService = auditService;
    this.diagramStore = diagramStore;
    this.sensitiveKeysRepository = sensitiveKeysRepository;
}

getConfig() — return wrapped response:

@GetMapping("/{application}")
@Operation(summary = "Get application config",
        description = "Returns the current configuration for an application with merged sensitive keys.")
@ApiResponse(responseCode = "200", description = "Config returned")
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
                                                     HttpServletRequest httpRequest) {
    auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
    ApplicationConfig config = configRepository.findByApplication(application)
            .orElse(defaultConfig(application));

    List<String> globalKeys = sensitiveKeysRepository.find()
            .map(SensitiveKeysConfig::keys)
            .orElse(null);
    List<String> merged = SensitiveKeysMerger.merge(globalKeys, config.getSensitiveKeys());

    return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
}

updateConfig() — merge before push:

In the updateConfig method, after ApplicationConfig saved = configRepository.save(...), merge the keys before pushing:

// Merge global + per-app sensitive keys for the SSE payload
List<String> globalKeys = sensitiveKeysRepository.find()
        .map(SensitiveKeysConfig::keys)
        .orElse(null);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, saved.getSensitiveKeys());

// Push with merged keys
ApplicationConfig pushConfig = cloneWithMergedKeys(saved, mergedKeys);
CommandGroupResponse pushResult = pushConfigToAgents(application, environment, pushConfig);

Add a private helper:

private ApplicationConfig cloneWithMergedKeys(ApplicationConfig source, List<String> mergedKeys) {
    String json;
    try {
        json = objectMapper.writeValueAsString(source);
        ApplicationConfig clone = objectMapper.readValue(json, ApplicationConfig.class);
        clone.setSensitiveKeys(mergedKeys);
        return clone;
    } catch (JsonProcessingException e) {
        log.warn("Failed to clone config for key merge, pushing original", e);
        return source;
    }
}

Also add imports at the top of the file:

import com.cameleer3.server.app.dto.AppConfigResponse;
import com.cameleer3.server.core.admin.SensitiveKeysConfig;
import com.cameleer3.server.core.admin.SensitiveKeysMerger;
import com.cameleer3.server.core.admin.SensitiveKeysRepository;
  • Step 3: Verify compilation

Run: mvn clean compile -pl cameleer3-server-core,cameleer3-server-app Expected: BUILD SUCCESS

  • Step 4: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AppConfigResponse.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java
git commit -m "feat: merge global sensitive keys into app config GET and SSE push"

Task 7: Integration Test — SensitiveKeysAdminController

Files:

  • Create: cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SensitiveKeysAdminControllerIT.java

  • Step 1: Write integration tests

Follow the ThresholdAdminControllerIT pattern.

package com.cameleer3.server.app.controller;

import com.cameleer3.server.app.AbstractPostgresIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.assertj.core.api.Assertions.assertThat;

class SensitiveKeysAdminControllerIT extends AbstractPostgresIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private TestSecurityHelper securityHelper;

    private String adminJwt;
    private String viewerJwt;

    @BeforeEach
    void setUp() {
        adminJwt = securityHelper.adminToken();
        viewerJwt = securityHelper.viewerToken();
        jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'sensitive_keys'");
    }

    @Test
    void get_notConfigured_returns204() {
        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/sensitive-keys", HttpMethod.GET,
                new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
    }

    @Test
    void get_asViewer_returns403() {
        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/sensitive-keys", HttpMethod.GET,
                new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void put_savesAndReturnsKeys() throws Exception {
        String json = """
                { "keys": ["Authorization", "Cookie", "*password*"] }
                """;

        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/sensitive-keys", HttpMethod.PUT,
                new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.path("keys").size()).isEqualTo(3);
        assertThat(body.path("keys").get(0).asText()).isEqualTo("Authorization");
        assertThat(body.path("pushResult").isNull()).isTrue();
    }

    @Test
    void put_thenGet_returnsStoredKeys() throws Exception {
        String json = """
                { "keys": ["Authorization", "*secret*"] }
                """;

        restTemplate.exchange(
                "/api/v1/admin/sensitive-keys", HttpMethod.PUT,
                new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
                String.class);

        ResponseEntity<String> getResponse = restTemplate.exchange(
                "/api/v1/admin/sensitive-keys", HttpMethod.GET,
                new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
                String.class);

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        JsonNode body = objectMapper.readTree(getResponse.getBody());
        assertThat(body.path("keys").size()).isEqualTo(2);
    }

    @Test
    void put_withPushToAgents_returnsEmptyPushResult() throws Exception {
        // No agents connected, but push should still succeed with 0 agents
        String json = """
                { "keys": ["Authorization"] }
                """;

        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/sensitive-keys?pushToAgents=true", HttpMethod.PUT,
                new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.path("pushResult").path("total").asInt()).isEqualTo(0);
    }

    @Test
    void put_asViewer_returns403() {
        String json = """
                { "keys": ["Authorization"] }
                """;

        ResponseEntity<String> response = restTemplate.exchange(
                "/api/v1/admin/sensitive-keys", HttpMethod.PUT,
                new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
                String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }
}
  • Step 2: Run tests

Run: mvn verify -pl cameleer3-server-app -Dit.test=SensitiveKeysAdminControllerIT -Dsurefire.skip=true Expected: all 5 tests PASS

  • Step 3: Commit
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SensitiveKeysAdminControllerIT.java
git commit -m "test: add SensitiveKeysAdminController integration tests"

Task 8: UI — Sensitive Keys API Query Hook

Files:

  • Create: ui/src/api/queries/admin/sensitive-keys.ts

  • Step 1: Create the query hook file

Follow the thresholds.ts pattern.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';

// ── Types ──────────────────────────────────────────────────────────────

export interface SensitiveKeysConfig {
  keys: string[];
}

export interface SensitiveKeysResponse {
  keys: string[];
  pushResult: {
    success: boolean;
    total: number;
    responded: number;
    responses: { agentId: string; status: string; message: string | null }[];
    timedOut: string[];
  } | null;
}

// ── Query Hooks ────────────────────────────────────────────────────────

export function useSensitiveKeys() {
  return useQuery({
    queryKey: ['admin', 'sensitive-keys'],
    queryFn: () => adminFetch<SensitiveKeysConfig | null>('/sensitive-keys'),
  });
}

// ── Mutation Hooks ─────────────────────────────────────────────────────

export function useUpdateSensitiveKeys() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ keys, pushToAgents }: { keys: string[]; pushToAgents: boolean }) =>
      adminFetch<SensitiveKeysResponse>(`/sensitive-keys?pushToAgents=${pushToAgents}`, {
        method: 'PUT',
        body: JSON.stringify({ keys }),
      }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'sensitive-keys'] });
    },
  });
}
  • Step 2: Commit
git add ui/src/api/queries/admin/sensitive-keys.ts
git commit -m "feat: add sensitive keys API query hooks"

Task 9: UI — SensitiveKeysPage Admin Page

Files:

  • Create: ui/src/pages/Admin/SensitiveKeysPage.tsx

  • Create: ui/src/pages/Admin/SensitiveKeysPage.module.css

  • Step 1: Create the CSS module

.page {
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
  max-width: 720px;
}

.infoBanner {
  font-size: var(--font-size-sm);
  color: var(--text-secondary);
  background: var(--surface-secondary);
  padding: var(--space-md);
  border-radius: var(--radius-md);
  line-height: 1.5;
}

.pillList {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
  min-height: 36px;
  align-items: center;
}

.inputRow {
  display: flex;
  gap: var(--space-sm);
  align-items: center;
}

.inputRow input {
  flex: 1;
}

.footer {
  display: flex;
  gap: var(--space-sm);
  align-items: center;
}
  • Step 2: Create the page component
import { useState, useEffect, useCallback } from 'react';
import { Button, SectionHeader, Tag, Input, Toggle, Label, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useSensitiveKeys, useUpdateSensitiveKeys } from '../../api/queries/admin/sensitive-keys';
import styles from './SensitiveKeysPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';

export default function SensitiveKeysPage() {
  const { data, isLoading } = useSensitiveKeys();
  const updateKeys = useUpdateSensitiveKeys();
  const { toast } = useToast();

  const [draft, setDraft] = useState<string[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [pushToAgents, setPushToAgents] = useState(false);
  const [initialized, setInitialized] = useState(false);

  useEffect(() => {
    if (data !== undefined && !initialized) {
      setDraft(data?.keys ?? []);
      setInitialized(true);
    }
  }, [data, initialized]);

  const addKey = useCallback(() => {
    const trimmed = inputValue.trim();
    if (!trimmed) return;
    if (draft.some((k) => k.toLowerCase() === trimmed.toLowerCase())) {
      toast({ title: 'Duplicate key', description: `"${trimmed}" is already in the list`, variant: 'warning' });
      return;
    }
    setDraft((prev) => [...prev, trimmed]);
    setInputValue('');
  }, [inputValue, draft, toast]);

  const removeKey = useCallback((index: number) => {
    setDraft((prev) => prev.filter((_, i) => i !== index));
  }, []);

  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      addKey();
    }
  }, [addKey]);

  function handleSave() {
    updateKeys.mutate({ keys: draft, pushToAgents }, {
      onSuccess: (result) => {
        if (result.pushResult) {
          toast({
            title: 'Sensitive keys saved and pushed',
            description: `${result.pushResult.total} agent(s) notified, ${result.pushResult.responded} responded`,
            variant: result.pushResult.success ? 'success' : 'warning',
          });
        } else {
          toast({ title: 'Sensitive keys saved', variant: 'success' });
        }
      },
      onError: () => {
        toast({ title: 'Failed to save sensitive keys', variant: 'error', duration: 86_400_000 });
      },
    });
  }

  if (isLoading) return <PageLoader />;

  return (
    <div className={styles.page}>
      <SectionHeader>Sensitive Keys</SectionHeader>

      <div className={styles.infoBanner}>
        Agents ship with built-in defaults (Authorization, Cookie, Set-Cookie, X-API-Key,
        X-Auth-Token, Proxy-Authorization). Configuring keys here replaces agent defaults for
        all applications. Leave unconfigured to use agent defaults.
      </div>

      <div className={sectionStyles.section}>
        <Label>Global sensitive keys</Label>
        <div className={styles.pillList}>
          {draft.map((key, i) => (
            <Tag key={`${key}-${i}`} label={key} onRemove={() => removeKey(i)} />
          ))}
          {draft.length === 0 && (
            <span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--font-size-sm)' }}>
              No keys configured  agents use built-in defaults
            </span>
          )}
        </div>

        <div className={styles.inputRow}>
          <Input
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="Add key or glob pattern (e.g. *password*)"
          />
          <Button variant="secondary" size="sm" onClick={addKey} disabled={!inputValue.trim()}>
            Add
          </Button>
        </div>
      </div>

      <div className={styles.footer}>
        <Toggle
          checked={pushToAgents}
          onChange={(e) => setPushToAgents((e.target as HTMLInputElement).checked)}
          label="Push to all connected agents immediately"
        />
        <Button variant="primary" onClick={handleSave} loading={updateKeys.isPending}>
          Save
        </Button>
      </div>
    </div>
  );
}
  • Step 3: Commit
git add ui/src/pages/Admin/SensitiveKeysPage.tsx ui/src/pages/Admin/SensitiveKeysPage.module.css
git commit -m "feat: add SensitiveKeysPage admin page"

Task 10: UI — Wire Up Router and Sidebar

Files:

  • Modify: ui/src/router.tsx

  • Modify: ui/src/components/sidebar-utils.ts

  • Step 1: Add lazy import and route to router.tsx

In ui/src/router.tsx, add the lazy import near the other admin page imports:

const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));

Add the route inside the admin children array, between oidc and rbac (alphabetical by path):

{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
  • Step 2: Add sidebar node to sidebar-utils.ts

In ui/src/components/sidebar-utils.ts, inside buildAdminTreeNodes(), add the node between the OIDC and Users & Roles entries:

{ id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },

The full nodes array should read:

const nodes: SidebarTreeNode[] = [
  { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
  ...(showInfra ? [{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }] : []),
  ...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []),
  { id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
  { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
  { id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },
  { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
];
  • Step 3: Verify dev server starts

Run: cd ui && npm run dev Navigate to /admin/sensitive-keys in the browser. Verify:

  • Page loads with info banner and empty pill editor

  • Sidebar shows "Sensitive Keys" entry

  • Adding/removing pills works

  • Save calls the API

  • Step 4: Commit

git add ui/src/router.tsx ui/src/components/sidebar-utils.ts
git commit -m "feat: wire SensitiveKeysPage into router and admin sidebar"

Task 11: UI — Per-App Sensitive Keys in AppConfigDetailPage

Files:

  • Modify: ui/src/api/queries/commands.ts

  • Modify: ui/src/pages/Admin/AppConfigDetailPage.tsx

  • Step 1: Update ApplicationConfig TypeScript interface

In ui/src/api/queries/commands.ts, add to the ApplicationConfig interface:

sensitiveKeys?: string[];
globalSensitiveKeys?: string[];
mergedSensitiveKeys?: string[];

The interface should now look like:

export interface ApplicationConfig {
  application: string
  version: number
  updatedAt?: string
  engineLevel?: string
  payloadCaptureMode?: string
  applicationLogLevel?: string
  agentLogLevel?: string
  metricsEnabled: boolean
  samplingRate: number
  tracedProcessors: Record<string, string>
  taps: TapDefinition[]
  tapVersion: number
  routeRecording: Record<string, boolean>
  compressSuccess: boolean
  sensitiveKeys?: string[]
  globalSensitiveKeys?: string[]
  mergedSensitiveKeys?: string[]
}

Note: The GET endpoint now returns AppConfigResponse which wraps config + these extra fields. Update useApplicationConfig to unwrap the response. The query hook (in commands.ts) currently calls GET /api/v1/config/{app} — check its shape. Since the server response shape changed from ApplicationConfig to AppConfigResponse, the query hook needs to map the response:

Find the useApplicationConfig hook and update it. The response now has shape { config: ApplicationConfig, globalSensitiveKeys: [...], mergedSensitiveKeys: [...] }. Flatten it so consumers still get an ApplicationConfig with the extra fields populated:

export function useApplicationConfig(application?: string) {
  return useQuery({
    queryKey: ['config', application],
    queryFn: async () => {
      const res = await authFetch(`/config/${application}`)
      if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
      const data = await res.json()
      // Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
      const cfg = data.config ?? data
      cfg.globalSensitiveKeys = data.globalSensitiveKeys ?? null
      cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
      return cfg as ApplicationConfig
    },
    enabled: !!application,
  })
}
  • Step 2: Add sensitive keys section to AppConfigDetailPage

In ui/src/pages/Admin/AppConfigDetailPage.tsx, add state for the sensitive keys draft:

const [sensitiveKeysDraft, setSensitiveKeysDraft] = useState<string[]>([]);
const [sensitiveKeyInput, setSensitiveKeyInput] = useState('');

In the useEffect that initializes from config, add:

setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);

In startEditing(), add:

setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);

In handleSave(), include the keys in the updated config:

const updated: ApplicationConfig = {
  ...config,
  ...form,
  tracedProcessors: tracedDraft,
  routeRecording: routeRecordingDraft,
  sensitiveKeys: sensitiveKeysDraft.length > 0 ? sensitiveKeysDraft : undefined,
} as ApplicationConfig;

Add the UI section after the Route Recording section, before the closing </div>:

{/* ── Sensitive Keys ──────────────────────────────────────────── */}
<div className={sectionStyles.section}>
  <SectionHeader>Sensitive Keys</SectionHeader>
  {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 ? (
    <span className={styles.sectionSummary}>
      {config.globalSensitiveKeys.length} global (enforced) &middot; {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length} app-specific
    </span>
  ) : (
    <span className={styles.sectionSummary}>
      No global sensitive keys configured. Agents use their built-in defaults.
    </span>
  )}

  {/* Global keys — read-only pills */}
  {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
    <div className={styles.sensitiveKeysRow}>
      <Label>Global (enforced)</Label>
      <div className={styles.pillList}>
        {config.globalSensitiveKeys.map((key) => (
          <Badge key={key} label={key} color="auto" variant="filled" />
        ))}
      </div>
    </div>
  )}

  {/* Per-app keys — editable */}
  <div className={styles.sensitiveKeysRow}>
    <Label>Application-specific</Label>
    <div className={styles.pillList}>
      {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).map((key, i) => (
        editing ? (
          <Tag key={`${key}-${i}`} label={key} onRemove={() => setSensitiveKeysDraft((prev) => prev.filter((_, idx) => idx !== i))} />
        ) : (
          <Badge key={key} label={key} color="primary" variant="filled" />
        )
      ))}
      {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length === 0 && (
        <span className={styles.hint}>None</span>
      )}
    </div>
    {editing && (
      <div className={styles.sensitiveKeyInput}>
        <input
          className={styles.numberInput}
          style={{ flex: 1 }}
          value={sensitiveKeyInput}
          onChange={(e) => setSensitiveKeyInput(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              const trimmed = sensitiveKeyInput.trim();
              if (trimmed && !sensitiveKeysDraft.some((k) => k.toLowerCase() === trimmed.toLowerCase())) {
                setSensitiveKeysDraft((prev) => [...prev, trimmed]);
                setSensitiveKeyInput('');
              }
            }
          }}
          placeholder="Add key or glob pattern"
        />
      </div>
    )}
  </div>

  {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
    <span className={styles.hint}>
      Global keys are enforced by your administrator and cannot be removed per-app.
    </span>
  )}
</div>
  • Step 3: Add CSS for the new section

In ui/src/pages/Admin/AppConfigDetailPage.module.css, add:

.sensitiveKeysRow {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}

.pillList {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
  min-height: 28px;
  align-items: center;
}

.sensitiveKeyInput {
  display: flex;
  gap: var(--space-sm);
  max-width: 400px;
}
  • Step 4: Add Tag import

At the top of AppConfigDetailPage.tsx, add Tag to the design-system import:

import {
  Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, Tag, useToast,
} from '@cameleer/design-system';
  • Step 5: Verify in browser

Start the dev server, navigate to an app config page. Verify:

  • Global keys appear as greyed-out badges

  • Per-app keys appear as editable tags in edit mode

  • Adding/removing per-app keys works

  • Save includes sensitive keys in the payload

  • Step 6: Commit

git add ui/src/api/queries/commands.ts ui/src/pages/Admin/AppConfigDetailPage.tsx ui/src/pages/Admin/AppConfigDetailPage.module.css
git commit -m "feat: add per-app sensitive keys section to AppConfigDetailPage"

Task 12: Full Compilation and Test Verification

Files: None (verification only)

  • Step 1: Full backend build

Run: mvn clean compile test-compile Expected: BUILD SUCCESS

  • Step 2: Run unit tests

Run: mvn test -pl cameleer3-server-core Expected: all tests pass, including SensitiveKeysMergerTest

  • Step 3: Run frontend type check

Run: cd ui && npx tsc --noEmit Expected: no type errors

  • Step 4: Regenerate openapi.json

Since backend REST API changed (new endpoint, modified response shapes), regenerate the OpenAPI spec:

Run the server and fetch the spec, or run mvn verify if the project generates it during build.

  • Step 5: Commit any remaining changes
git add -A
git commit -m "chore: regenerate openapi.json after sensitive keys API changes"