From 891abbfcfdb67ad1fa4152b5a3a5a2fcc5542b65 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:29:15 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 14 +- .../plans/2026-04-14-sensitive-keys-server.md | 1389 +++++++++++++++++ ...2026-04-14-sensitive-keys-server-design.md | 222 +++ 3 files changed, 1623 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-14-sensitive-keys-server.md create mode 100644 docs/superpowers/specs/2026-04-14-sensitive-keys-server-design.md diff --git a/CLAUDE.md b/CLAUDE.md index 23d2c58d..92efee73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,11 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - `SystemRole` — enum: AGENT, VIEWER, OPERATOR, ADMIN; `normalizeScope()` maps scopes - `UserDetail`, `RoleDetail`, `GroupDetail` — records +**admin/** — Server-wide admin config +- `SensitiveKeysConfig` — record: keys (List, immutable) +- `SensitiveKeysRepository` — interface: find(), save() +- `SensitiveKeysMerger` — pure function: merge(global, perApp) -> union with case-insensitive dedup, preserves first-seen casing. Returns null when both inputs null. + **security/** — Auth - `JwtService` — interface: createAccessToken, validateAccessToken - `Ed25519SigningService` — interface: sign, verify (config signing) @@ -95,6 +100,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - `RoleAdminController` — CRUD /api/v1/admin/roles - `GroupAdminController` — CRUD /api/v1/admin/groups - `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test +- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true` to fan out merged keys to all LIVE agents. Stored in `server_config` table (key `sensitive_keys`). - `AuditLogController` — GET /api/v1/admin/audit - `MetricsController` — GET /api/v1/metrics, GET /timeseries - `DiagramController` — GET /api/v1/diagrams/{id}, POST / @@ -117,7 +123,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository` - `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId - `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository` -- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository` +- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository` **storage/** — ClickHouse stores - `ClickHouseExecutionStore`, `ClickHouseMetricsStore`, `ClickHouseLogStore` @@ -163,6 +169,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups (e.g., reverse proxy). Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints (set by SaaS provisioner on tenant servers). Health endpoint exposes the flag for UI tab visibility. UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER. Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict on role removal, user deletion, group role removal). Password policy: min 12 chars, 3-of-4 character classes, no username match (enforced on user creation and admin password reset). Brute-force protection: 5 failed attempts -> 15 min lockout (tracked via `failed_login_attempts` / `locked_until` on users table). Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change. - OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. `CAMELEER_SERVER_SECURITY_OIDCJWKSETURI` overrides JWKS discovery for container networking. `CAMELEER_SERVER_SECURITY_OIDCTLSSKIPVERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` -> ADMIN, `operator`/`server:operator` -> OPERATOR, `viewer`/`server:viewer` -> VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login via `syncOidcRoles` — always overwrites directly-assigned roles (falls back to `defaultRoles` when OIDC returns none); uses `getDirectRolesForUser` to avoid touching group-inherited roles. Group memberships are never touched. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set). - OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type, decoded by a separate processor), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator — included in both authorization request and token exchange POST body to trigger JWT access tokens) and `additionalScopes` (extra scopes for the SPA to request). The `rolesClaim` config points to the claim name in the token (e.g., `"roles"` for Custom JWT claims, `"realm_access.roles"` for Keycloak). All provider-specific configuration is external — no provider-specific code in the server. +- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Admin configures via `PUT /api/v1/admin/sensitive-keys` (stored in `server_config` table, key `sensitive_keys`). Per-app additions stored in `ApplicationConfig.sensitiveKeys`. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys). When no config exists, agents use built-in defaults. `ApplicationConfigController.getConfig()` returns `AppConfigResponse` wrapping config with `globalSensitiveKeys` and `mergedSensitiveKeys` for UI rendering. Config-update SSE payloads carry the merged list. SaaS propagation: platform calls the same admin API on each tenant server (no special protocol). - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` - Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s @@ -211,6 +218,9 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments - Create app: full page at `/apps/new` (not a modal) - Deployment progress: `ui/src/components/DeploymentProgress.tsx` (7-stage step indicator) +**Admin pages** (ADMIN-only, under `/admin/`): +- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config with tag/pill editor, push-to-agents toggle. Per-app additions shown in `AppConfigDetailPage.tsx` with read-only global pills (greyed Badge) + editable per-app pills (Tag with remove). + ### Key UI Files - `ui/src/router.tsx` — React Router v6 routes @@ -381,7 +391,7 @@ Mean processing time = `camel.route.policy.total_time / camel.route.policy.count # GitNexus — Code Intelligence -This project is indexed by GitNexus as **cameleer3-server** (6027 symbols, 15299 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **cameleer3-server** (6155 symbols, 15501 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/docs/superpowers/plans/2026-04-14-sensitive-keys-server.md b/docs/superpowers/plans/2026-04-14-sensitive-keys-server.md new file mode 100644 index 00000000..19be34fd --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-sensitive-keys-server.md @@ -0,0 +1,1389 @@ +# 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 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 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 result = SensitiveKeysMerger.merge( + List.of("Authorization", "Cookie"), null); + assertEquals(List.of("Authorization", "Cookie"), result); + } + + @Test + void perAppOnly_returnsPerApp() { + List result = SensitiveKeysMerger.merge( + null, List.of("X-Internal-*")); + assertEquals(List.of("X-Internal-*"), result); + } + + @Test + void union_mergesWithoutDuplicates() { + List 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 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 result = SensitiveKeysMerger.merge(List.of(), null); + assertEquals(List.of(), result); + } + + @Test + void emptyGlobalWithPerApp_returnsPerApp() { + // Explicit empty global + per-app additions = just per-app + List result = SensitiveKeysMerger.merge( + List.of(), List.of("X-Custom")); + assertEquals(List.of("X-Custom"), result); + } + + @Test + void globPatterns_preserved() { + List 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 merge(List global, List perApp) { + if (global == null && perApp == null) { + return null; + } + + TreeSet seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + List 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 find() { + List 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 keys +) {} +``` + +- [ ] **Step 2: Create the response DTO** + +```java +package com.cameleer3.server.app.dto; + +import java.util.List; + +public record SensitiveKeysResponse( + List 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 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 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 globalKeys) { + // Collect distinct applications from stored configs + live agents + Set 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 allResponses = new ArrayList<>(); + List allTimedOut = new ArrayList<>(); + int totalAgents = 0; + + for (String application : applications) { + ApplicationConfig appConfig = configRepository.findByApplication(application) + .orElse(null); + List perAppKeys = appConfig != null ? appConfig.getSensitiveKeys() : null; + List 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> 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 globalSensitiveKeys, + List 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 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 globalKeys = sensitiveKeysRepository.find() + .map(SensitiveKeysConfig::keys) + .orElse(null); + List 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 globalKeys = sensitiveKeysRepository.find() + .map(SensitiveKeysConfig::keys) + .orElse(null); +List 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 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 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 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 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 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 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 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('/sensitive-keys'), + }); +} + +// ── Mutation Hooks ───────────────────────────────────────────────────── + +export function useUpdateSensitiveKeys() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ keys, pushToAgents }: { keys: string[]; pushToAgents: boolean }) => + adminFetch(`/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([]); + 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 ; + + return ( +
+ Sensitive Keys + +
+ 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. +
+ +
+ +
+ {draft.map((key, i) => ( + removeKey(i)} /> + ))} + {draft.length === 0 && ( + + No keys configured — agents use built-in defaults + + )} +
+ +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add key or glob pattern (e.g. *password*)" + /> + +
+
+ +
+ setPushToAgents((e.target as HTMLInputElement).checked)} + label="Push to all connected agents immediately" + /> + +
+
+ ); +} +``` + +- [ ] **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: }, +``` + +- [ ] **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 + taps: TapDefinition[] + tapVersion: number + routeRecording: Record + 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([]); +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 ``: + +```tsx +{/* ── Sensitive Keys ──────────────────────────────────────────── */} +
+ Sensitive Keys + {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 ? ( + + {config.globalSensitiveKeys.length} global (enforced) · {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length} app-specific + + ) : ( + + No global sensitive keys configured. Agents use their built-in defaults. + + )} + + {/* Global keys — read-only pills */} + {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && ( +
+ +
+ {config.globalSensitiveKeys.map((key) => ( + + ))} +
+
+ )} + + {/* Per-app keys — editable */} +
+ +
+ {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).map((key, i) => ( + editing ? ( + setSensitiveKeysDraft((prev) => prev.filter((_, idx) => idx !== i))} /> + ) : ( + + ) + ))} + {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length === 0 && ( + None + )} +
+ {editing && ( +
+ 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" + /> +
+ )} +
+ + {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && ( + + Global keys are enforced by your administrator and cannot be removed per-app. + + )} +
+``` + +- [ ] **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" +``` diff --git a/docs/superpowers/specs/2026-04-14-sensitive-keys-server-design.md b/docs/superpowers/specs/2026-04-14-sensitive-keys-server-design.md new file mode 100644 index 00000000..58a3432a --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-sensitive-keys-server-design.md @@ -0,0 +1,222 @@ +# Sensitive Keys Server-Side Support + +**Date:** 2026-04-14 +**Status:** Approved + +## Context + +The agent team is unifying `sensitiveHeaders` and `sensitiveProperties` into a single `sensitiveKeys` field on `ApplicationConfig` (see agent spec: "Sensitive Keys Unification + SSE Support + Pattern Matching"). The server must store, merge, and push these keys to agents. + +Key requirements beyond the agent contract: +- Global enforced baseline that admins control +- Per-app additions (cannot weaken the global baseline) +- Immediate fan-out option when global keys change +- SaaS tenant admins configure via the same REST API (no special protocol) + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Override hierarchy | Global enforced + per-app additive | Security: devs cannot accidentally un-mask patterns | +| Merge semantics | Union only, no remove | Simplicity; weakening the baseline is not a valid use case | +| Server defaults | None (null until configured) | Agents have built-in defaults; avoids drift between server and agent hardcoded lists | +| Fan-out on global change | Admin chooses via `pushToAgents` param | Flexibility; some admins want to batch changes | +| SaaS propagation | SaaS platform calls the same admin API | No special SaaS-aware code in the server | +| Storage | Existing `server_config` table | Follows OIDC and thresholds pattern | + +## Merge Rule + +``` +final_keys = global_keys UNION per_app_keys +``` + +- Global keys are always included (enforced baseline) +- Per-app keys are additive only +- Deduplication is case-insensitive (preserves the casing of the first occurrence) +- If no global config exists AND no per-app keys exist, `sensitiveKeys` is omitted from the payload (agents use built-in defaults) +- If global config exists but is an empty list `[]`, the server sends `[]` (agents mask nothing — explicit opt-out) + +## API + +### Global Sensitive Keys (new) + +**GET /api/v1/admin/sensitive-keys** +- Auth: ADMIN only +- Returns the stored global keys or `null` if not configured +- Response: + ```json + { + "keys": ["Authorization", "Cookie", "Set-Cookie", "*password*", "*secret*"] + } + ``` + Or `null` body (204 No Content) when not configured. + +**PUT /api/v1/admin/sensitive-keys?pushToAgents=false** +- Auth: ADMIN only +- Request body: + ```json + { + "keys": ["Authorization", "Cookie", "Set-Cookie", "*password*", "*secret*"] + } + ``` +- `pushToAgents` query param (boolean, default `false`): + - `false`: saves only, new keys take effect on each app's next config push + - `true`: saves, then fans out config-update to all LIVE agents (recomputes merged list per app) +- Response: saved config + optional push results + ```json + { + "keys": ["Authorization", "Cookie", "Set-Cookie", "*password*", "*secret*"], + "pushResult": { ... } + } + ``` + `pushResult` is `null` when `pushToAgents=false`. + +### Per-App Config (existing endpoint, enhanced behavior) + +**GET /api/v1/config/{application}** +- `sensitiveKeys`: the **per-app additions only** (what the UI edits and PUTs back) +- `globalSensitiveKeys`: read-only field, the current global baseline (for UI to render greyed-out pills) +- `mergedSensitiveKeys`: the full merged list (global + per-app) — informational, shows what agents actually receive +- Note: the agent-facing SSE payload uses `sensitiveKeys` for the merged list (agents don't need to know the split) + +**PUT /api/v1/config/{application}** +- `sensitiveKeys` in the request body represents **per-app additions only** (not the merged list) +- `globalSensitiveKeys` and `mergedSensitiveKeys` are ignored if sent in the PUT body +- On save: only per-app `sensitiveKeys` are persisted +- On push: server merges global + per-app into the `sensitiveKeys` field of the SSE payload sent to agents + +## Storage + +No new tables. Uses existing PostgreSQL schema. + +### Global Keys + +```sql +-- server_config table +-- config_key = 'sensitive_keys' +-- config_val example: +{ "keys": ["Authorization", "Cookie", "Set-Cookie", "*password*", "*secret*"] } +``` + +### Per-App Keys + +Stored in `application_config.config_val` as part of the existing `ApplicationConfig` JSONB. The `sensitiveKeys` field (added by the agent team in `cameleer3-common`) stores only the per-app additions. + +## Fan-Out on Global Change + +When admin PUTs global keys with `pushToAgents=true`: + +1. Save to `server_config` +2. Collect all distinct applications from `application_config` table + live agents in registry +3. For each application: + a. Load per-app config (or defaults if none) + b. Merge global + per-app keys + c. Set merged keys on the ApplicationConfig + d. Push via existing `pushConfigToAgents()` mechanism +4. Return aggregate push results (total apps, total agents, per-app response summary) +5. Audit log entry with results + +## Server Code Changes + +### New Files + +| File | Module | Purpose | +|------|--------|---------| +| `SensitiveKeysConfig.java` | core | `record SensitiveKeysConfig(List keys)` | +| `SensitiveKeysRepository.java` | core | Interface: `find()`, `save()` | +| `SensitiveKeysMerger.java` | core | Pure function: `merge(List global, List perApp) -> List`. Union, case-insensitive dedup, preserves first-seen casing. | +| `PostgresSensitiveKeysRepository.java` | app/storage | Read/write `server_config` key `"sensitive_keys"` (follows `PostgresThresholdRepository` pattern) | +| `SensitiveKeysAdminController.java` | app/controller | GET/PUT `/api/v1/admin/sensitive-keys`, fan-out logic, audit logging | + +### Modified Files + +| File | Change | +|------|--------| +| `ApplicationConfigController.java` | Inject `SensitiveKeysRepository`. On GET: merge global keys into response, add `globalSensitiveKeys` field. On PUT: merge before SSE push. | +| `StorageBeanConfig.java` | Wire `PostgresSensitiveKeysRepository` bean | + +### No Schema Migration Required + +Uses existing `server_config` table (JSONB key-value store). The `sensitiveKeys` field on `ApplicationConfig` is added by the agent team in `cameleer3-common` — the server just reads/writes it as part of the existing JSONB blob. + +## Audit + +| Action | Category | Detail | +|--------|----------|--------| +| `view_sensitive_keys` | CONFIG | (none) | +| `update_sensitive_keys` | CONFIG | `{ keys: [...], pushToAgents: true/false, appsPushed: N, totalAgents: N }` | + +Per-app changes are covered by the existing `update_app_config` audit entry. + +## UI + +### Global Sensitive Keys Admin Page + +- **Location:** Admin sidebar, new entry "Sensitive Keys" +- **Access:** ADMIN only (sidebar entry hidden for non-ADMIN) +- **Components:** + - Info banner at top: "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." + - Tag/pill editor for the keys list. Type a key or glob pattern, press Enter to add as a pill. Each pill has an X to remove. Supports glob patterns (`*password*`, `X-Internal-*`). + - "Push to all connected agents immediately" toggle (default off) + - Save button +- **Empty state:** Info banner + empty editor. Clear that agents use their own defaults. + +### Per-App Sensitive Keys (existing app config page) + +- **Location:** Within the existing per-app config editor, new section "Additional Sensitive Keys" +- **Components:** + - Read-only pills showing current global keys (greyed out, no X button, visually distinct) + - Editable tag/pill editor for per-app additions (normal styling, X to remove) + - Info note: "Global keys (shown in grey) are enforced by your administrator and cannot be removed. Add application-specific keys below." +- **When no global keys configured:** Section shows only the editable per-app editor with a note: "No global sensitive keys configured. Agents use their built-in defaults." + +## SaaS Integration + +No server-side changes needed for SaaS. The SaaS platform propagates tenant-level sensitive keys by calling the standard admin API: + +``` +PUT https://{tenant-server}/api/v1/admin/sensitive-keys?pushToAgents=true +Authorization: Bearer {platform-admin-token} +{ + "keys": ["Authorization", "Cookie", "*password*", "*secret*"] +} +``` + +Each tenant server handles merge + fan-out to its own agents independently. + +## Sequence Diagrams + +### Admin Updates Global Keys (with push) + +``` +Admin UI Server Agents + │ │ │ + │ PUT /admin/sensitive-keys│ │ + │ { keys: [...] } │ │ + │ ?pushToAgents=true │ │ + │─────────────────────────>│ │ + │ │ save to server_config │ + │ │ │ + │ │ for each app: │ + │ │ merge(global, per-app) │ + │ │ CONFIG_UPDATE SSE ──────>│ + │ │ ACK <───│ + │ │ │ + │ { keys, pushResult } │ │ + │<─────────────────────────│ │ +``` + +### Agent Startup + +``` +Agent Server + │ │ + │ GET /config/{app} │ + │──────────────────────────>│ + │ │ load per-app config + │ │ load global sensitive keys + │ │ merge(global, per-app) + │ │ + │ { ..., sensitiveKeys: [merged] } + │<──────────────────────────│ +```