Compare commits
12 Commits
dcd0b4ebcd
...
891abbfcfd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
891abbfcfd | ||
|
|
7b73b5c9c5 | ||
|
|
96780db9ad | ||
|
|
813ec6904e | ||
|
|
06c719f0dd | ||
|
|
77aa3c3d6f | ||
|
|
2fad8811c6 | ||
|
|
28e38e4dee | ||
|
|
c3892151a5 | ||
|
|
84641fe81a | ||
|
|
d72a6511da | ||
|
|
86b6c85aa7 |
14
CLAUDE.md
14
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
|
- `SystemRole` — enum: AGENT, VIEWER, OPERATOR, ADMIN; `normalizeScope()` maps scopes
|
||||||
- `UserDetail`, `RoleDetail`, `GroupDetail` — records
|
- `UserDetail`, `RoleDetail`, `GroupDetail` — records
|
||||||
|
|
||||||
|
**admin/** — Server-wide admin config
|
||||||
|
- `SensitiveKeysConfig` — record: keys (List<String>, 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
|
**security/** — Auth
|
||||||
- `JwtService` — interface: createAccessToken, validateAccessToken
|
- `JwtService` — interface: createAccessToken, validateAccessToken
|
||||||
- `Ed25519SigningService` — interface: sign, verify (config signing)
|
- `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
|
- `RoleAdminController` — CRUD /api/v1/admin/roles
|
||||||
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
||||||
- `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test
|
- `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
|
- `AuditLogController` — GET /api/v1/admin/audit
|
||||||
- `MetricsController` — GET /api/v1/metrics, GET /timeseries
|
- `MetricsController` — GET /api/v1/metrics, GET /timeseries
|
||||||
- `DiagramController` — GET /api/v1/diagrams/{id}, POST /
|
- `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`
|
- `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository`
|
||||||
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
||||||
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
||||||
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`
|
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
|
||||||
|
|
||||||
**storage/** — ClickHouse stores
|
**storage/** — ClickHouse stores
|
||||||
- `ClickHouseExecutionStore`, `ClickHouseMetricsStore`, `ClickHouseLogStore`
|
- `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.
|
- 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: 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.
|
- 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`
|
- 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
|
- 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)
|
- Create app: full page at `/apps/new` (not a modal)
|
||||||
- Deployment progress: `ui/src/components/DeploymentProgress.tsx` (7-stage step indicator)
|
- 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
|
### Key UI Files
|
||||||
|
|
||||||
- `ui/src/router.tsx` — React Router v6 routes
|
- `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:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.common.model.ApplicationConfig;
|
import com.cameleer3.common.model.ApplicationConfig;
|
||||||
|
import com.cameleer3.server.app.dto.AppConfigResponse;
|
||||||
import com.cameleer3.server.app.dto.CommandGroupResponse;
|
import com.cameleer3.server.app.dto.CommandGroupResponse;
|
||||||
import com.cameleer3.server.app.dto.ConfigUpdateResponse;
|
import com.cameleer3.server.app.dto.ConfigUpdateResponse;
|
||||||
import com.cameleer3.server.app.dto.TestExpressionRequest;
|
import com.cameleer3.server.app.dto.TestExpressionRequest;
|
||||||
@@ -9,6 +10,9 @@ import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository;
|
|||||||
import com.cameleer3.server.core.admin.AuditCategory;
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
import com.cameleer3.server.core.admin.AuditResult;
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
import com.cameleer3.server.core.admin.AuditService;
|
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.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.agent.AgentState;
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
@@ -52,17 +56,20 @@ public class ApplicationConfigController {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final DiagramStore diagramStore;
|
private final DiagramStore diagramStore;
|
||||||
|
private final SensitiveKeysRepository sensitiveKeysRepository;
|
||||||
|
|
||||||
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
|
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
|
||||||
AgentRegistryService registryService,
|
AgentRegistryService registryService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
AuditService auditService,
|
AuditService auditService,
|
||||||
DiagramStore diagramStore) {
|
DiagramStore diagramStore,
|
||||||
|
SensitiveKeysRepository sensitiveKeysRepository) {
|
||||||
this.configRepository = configRepository;
|
this.configRepository = configRepository;
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.diagramStore = diagramStore;
|
this.diagramStore = diagramStore;
|
||||||
|
this.sensitiveKeysRepository = sensitiveKeysRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -76,14 +83,20 @@ public class ApplicationConfigController {
|
|||||||
|
|
||||||
@GetMapping("/{application}")
|
@GetMapping("/{application}")
|
||||||
@Operation(summary = "Get application config",
|
@Operation(summary = "Get application config",
|
||||||
description = "Returns the current configuration for an application. Returns defaults if none stored.")
|
description = "Returns the current configuration for an application with merged sensitive keys.")
|
||||||
@ApiResponse(responseCode = "200", description = "Config returned")
|
@ApiResponse(responseCode = "200", description = "Config returned")
|
||||||
public ResponseEntity<ApplicationConfig> getConfig(@PathVariable String application,
|
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
|
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok(
|
ApplicationConfig config = configRepository.findByApplication(application)
|
||||||
configRepository.findByApplication(application)
|
.orElse(defaultConfig(application));
|
||||||
.orElse(defaultConfig(application)));
|
|
||||||
|
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||||
|
.map(SensitiveKeysConfig::keys)
|
||||||
|
.orElse(null);
|
||||||
|
List<String> merged = SensitiveKeysMerger.merge(globalKeys, extractSensitiveKeys(config));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{application}")
|
@PutMapping("/{application}")
|
||||||
@@ -100,7 +113,15 @@ public class ApplicationConfigController {
|
|||||||
config.setApplication(application);
|
config.setApplication(application);
|
||||||
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
|
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
|
||||||
|
|
||||||
CommandGroupResponse pushResult = pushConfigToAgents(application, environment, saved);
|
// Merge global + per-app sensitive keys for the SSE push payload
|
||||||
|
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||||
|
.map(SensitiveKeysConfig::keys)
|
||||||
|
.orElse(null);
|
||||||
|
List<String> perAppKeys = extractSensitiveKeys(saved);
|
||||||
|
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
||||||
|
|
||||||
|
// Push with merged sensitive keys injected into the payload
|
||||||
|
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(application, environment, saved, mergedKeys);
|
||||||
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
|
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
|
||||||
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
||||||
|
|
||||||
@@ -180,12 +201,37 @@ public class ApplicationConfigController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private CommandGroupResponse pushConfigToAgents(String application, String environment, ApplicationConfig config) {
|
/**
|
||||||
|
* Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time
|
||||||
|
* dependency on getSensitiveKeys() which may not be in the published cameleer3-common jar yet.
|
||||||
|
*/
|
||||||
|
private List<String> extractSensitiveKeys(ApplicationConfig config) {
|
||||||
|
try {
|
||||||
|
com.fasterxml.jackson.databind.JsonNode node = objectMapper.valueToTree(config);
|
||||||
|
com.fasterxml.jackson.databind.JsonNode keysNode = node.get("sensitiveKeys");
|
||||||
|
if (keysNode == null || keysNode.isNull() || !keysNode.isArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return objectMapper.convertValue(keysNode, new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push config to agents with merged sensitive keys injected into the JSON payload.
|
||||||
|
*/
|
||||||
|
private CommandGroupResponse pushConfigToAgentsWithMergedKeys(String application, String environment,
|
||||||
|
ApplicationConfig config, List<String> mergedKeys) {
|
||||||
String payloadJson;
|
String payloadJson;
|
||||||
try {
|
try {
|
||||||
payloadJson = objectMapper.writeValueAsString(config);
|
// Serialize config to a mutable map, inject merged keys
|
||||||
} catch (JsonProcessingException e) {
|
@SuppressWarnings("unchecked")
|
||||||
log.error("Failed to serialize config for push", e);
|
Map<String, Object> configMap = objectMapper.convertValue(config, Map.class);
|
||||||
|
configMap.put("sensitiveKeys", mergedKeys);
|
||||||
|
payloadJson = objectMapper.writeValueAsString(configMap);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to serialize config with merged keys for push", e);
|
||||||
return new CommandGroupResponse(false, 0, 0, List.of(), List.of());
|
return new CommandGroupResponse(false, 0, 0, List.of(), List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +242,6 @@ public class ApplicationConfigController {
|
|||||||
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
|
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait with shared 10-second deadline
|
|
||||||
long deadline = System.currentTimeMillis() + 10_000;
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
List<CommandGroupResponse.AgentResponse> responses = new ArrayList<>();
|
List<CommandGroupResponse.AgentResponse> responses = new ArrayList<>();
|
||||||
List<String> timedOut = new ArrayList<>();
|
List<String> timedOut = new ArrayList<>();
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.CommandReply;
|
||||||
|
import com.cameleer3.server.core.agent.CommandType;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
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.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
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")
|
||||||
|
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 configuration",
|
||||||
|
description = "Saves the global sensitive keys. Optionally fans out merged keys to all live agents.")
|
||||||
|
public ResponseEntity<SensitiveKeysResponse> updateSensitiveKeys(
|
||||||
|
@Valid @RequestBody SensitiveKeysRequest request,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") boolean pushToAgents,
|
||||||
|
Authentication auth,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
String updatedBy = auth != null ? auth.getName() : "system";
|
||||||
|
SensitiveKeysConfig config = new SensitiveKeysConfig(request.keys());
|
||||||
|
sensitiveKeysRepository.save(config, updatedBy);
|
||||||
|
|
||||||
|
CommandGroupResponse pushResult = null;
|
||||||
|
if (pushToAgents) {
|
||||||
|
pushResult = fanOutToAllAgents(config.keys());
|
||||||
|
log.info("Sensitive keys saved and pushed to all applications, {} agent(s) responded",
|
||||||
|
pushResult.responded());
|
||||||
|
} else {
|
||||||
|
log.info("Sensitive keys saved ({} keys), push skipped", config.keys().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
auditService.log("update_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys",
|
||||||
|
Map.of("keyCount", config.keys().size(), "pushToAgents", pushToAgents),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new SensitiveKeysResponse(config.keys(), pushResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan out the merged (global + per-app) sensitive keys to all known applications.
|
||||||
|
* Collects distinct application IDs from both stored configs and live agents.
|
||||||
|
* Builds a minimal JSON payload carrying only the sensitiveKeys field so that
|
||||||
|
* agents apply it as a partial config update.
|
||||||
|
* <p>
|
||||||
|
* Per-app keys are read via JsonNode rather than ApplicationConfig.getSensitiveKeys()
|
||||||
|
* to remain compatible with older published versions of cameleer3-common that may
|
||||||
|
* not yet include that field accessor.
|
||||||
|
*/
|
||||||
|
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
|
||||||
|
// Collect all distinct application IDs
|
||||||
|
Set<String> applications = new LinkedHashSet<>();
|
||||||
|
configRepository.findAll().stream()
|
||||||
|
.map(ApplicationConfig::getApplication)
|
||||||
|
.filter(a -> a != null && !a.isBlank())
|
||||||
|
.forEach(applications::add);
|
||||||
|
registryService.findAll().stream()
|
||||||
|
.map(a -> a.applicationId())
|
||||||
|
.filter(a -> a != null && !a.isBlank())
|
||||||
|
.forEach(applications::add);
|
||||||
|
|
||||||
|
if (applications.isEmpty()) {
|
||||||
|
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared 10-second deadline across all applications
|
||||||
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
|
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
|
||||||
|
List<String> allTimedOut = new ArrayList<>();
|
||||||
|
int totalAgents = 0;
|
||||||
|
|
||||||
|
for (String application : applications) {
|
||||||
|
// Load per-app sensitive keys via JsonNode to avoid dependency on
|
||||||
|
// ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet.
|
||||||
|
List<String> perAppKeys = configRepository.findByApplication(application)
|
||||||
|
.map(cfg -> extractSensitiveKeys(cfg))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
// Merge global + per-app keys
|
||||||
|
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
||||||
|
|
||||||
|
// Build a minimal payload map — only sensitiveKeys + application fields.
|
||||||
|
Map<String, Object> payloadMap = new LinkedHashMap<>();
|
||||||
|
payloadMap.put("application", application);
|
||||||
|
payloadMap.put("sensitiveKeys", mergedKeys);
|
||||||
|
|
||||||
|
String payloadJson;
|
||||||
|
try {
|
||||||
|
payloadJson = objectMapper.writeValueAsString(payloadMap);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Failed to serialize sensitive keys push payload for application '{}'", application, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, CompletableFuture<CommandReply>> futures =
|
||||||
|
registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson);
|
||||||
|
|
||||||
|
totalAgents += futures.size();
|
||||||
|
|
||||||
|
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 CommandGroupResponse(allSuccess, totalAgents, allResponses.size(), allResponses, allTimedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the sensitiveKeys list from an ApplicationConfig by round-tripping through
|
||||||
|
* JsonNode. This avoids a compile-time dependency on ApplicationConfig.getSensitiveKeys()
|
||||||
|
* which may not be present in older published versions of cameleer3-common.
|
||||||
|
*/
|
||||||
|
private List<String> extractSensitiveKeys(ApplicationConfig config) {
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.valueToTree(config);
|
||||||
|
JsonNode keysNode = node.get("sensitiveKeys");
|
||||||
|
if (keysNode == null || keysNode.isNull() || !keysNode.isArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return objectMapper.convertValue(keysNode, new TypeReference<List<String>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to extract sensitiveKeys from ApplicationConfig", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record SensitiveKeysResponse(
|
||||||
|
List<String> keys,
|
||||||
|
CommandGroupResponse pushResult
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SensitiveKeysRepository {
|
||||||
|
Optional<SensitiveKeysConfig> find();
|
||||||
|
void save(SensitiveKeysConfig config, String updatedBy);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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() {
|
||||||
|
List<String> result = SensitiveKeysMerger.merge(List.of(), null);
|
||||||
|
assertEquals(List.of(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyGlobalWithPerApp_returnsPerApp() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1389
docs/superpowers/plans/2026-04-14-sensitive-keys-server.md
Normal file
1389
docs/superpowers/plans/2026-04-14-sensitive-keys-server.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<String> keys)` |
|
||||||
|
| `SensitiveKeysRepository.java` | core | Interface: `find()`, `save()` |
|
||||||
|
| `SensitiveKeysMerger.java` | core | Pure function: `merge(List<String> global, List<String> perApp) -> List<String>`. 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] }
|
||||||
|
│<──────────────────────────│
|
||||||
|
```
|
||||||
44
ui/src/api/queries/admin/sensitive-keys.ts
Normal file
44
ui/src/api/queries/admin/sensitive-keys.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export interface ApplicationConfig {
|
|||||||
tapVersion: number
|
tapVersion: number
|
||||||
routeRecording: Record<string, boolean>
|
routeRecording: Record<string, boolean>
|
||||||
compressSuccess: boolean
|
compressSuccess: boolean
|
||||||
|
sensitiveKeys?: string[]
|
||||||
|
globalSensitiveKeys?: string[]
|
||||||
|
mergedSensitiveKeys?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Authenticated fetch using the JWT from auth store. Paths are relative to apiBaseUrl. */
|
/** Authenticated fetch using the JWT from auth store. Paths are relative to apiBaseUrl. */
|
||||||
@@ -58,8 +61,13 @@ export function useApplicationConfig(application: string | undefined) {
|
|||||||
queryKey: ['applicationConfig', application],
|
queryKey: ['applicationConfig', application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await authFetch(`/config/${application}`)
|
const res = await authFetch(`/config/${application}`)
|
||||||
if (!res.ok) throw new Error('Failed to fetch config')
|
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
||||||
return res.json() as Promise<ApplicationConfig>
|
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,
|
enabled: !!application,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
|
|||||||
...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []),
|
...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []),
|
||||||
{ id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
|
{ id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
|
||||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
{ 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' },
|
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||||
];
|
];
|
||||||
return nodes;
|
return nodes;
|
||||||
|
|||||||
@@ -100,3 +100,23 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { ArrowLeft, Pencil, X } from 'lucide-react';
|
import { ArrowLeft, Pencil, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, useToast,
|
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, Tag, useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||||
@@ -85,6 +85,8 @@ export default function AppConfigDetailPage() {
|
|||||||
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
|
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
|
||||||
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
|
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
|
||||||
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
|
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
|
||||||
|
const [sensitiveKeysDraft, setSensitiveKeysDraft] = useState<string[]>([]);
|
||||||
|
const [sensitiveKeyInput, setSensitiveKeyInput] = useState('');
|
||||||
|
|
||||||
// Find routes for this application from the catalog
|
// Find routes for this application from the catalog
|
||||||
const appRoutes: CatalogRoute[] = useMemo(() => {
|
const appRoutes: CatalogRoute[] = useMemo(() => {
|
||||||
@@ -106,6 +108,7 @@ export default function AppConfigDetailPage() {
|
|||||||
});
|
});
|
||||||
setTracedDraft({ ...config.tracedProcessors });
|
setTracedDraft({ ...config.tracedProcessors });
|
||||||
setRouteRecordingDraft({ ...config.routeRecording });
|
setRouteRecordingDraft({ ...config.routeRecording });
|
||||||
|
setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ export default function AppConfigDetailPage() {
|
|||||||
});
|
});
|
||||||
setTracedDraft({ ...config.tracedProcessors });
|
setTracedDraft({ ...config.tracedProcessors });
|
||||||
setRouteRecordingDraft({ ...config.routeRecording });
|
setRouteRecordingDraft({ ...config.routeRecording });
|
||||||
|
setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +152,7 @@ export default function AppConfigDetailPage() {
|
|||||||
...form,
|
...form,
|
||||||
tracedProcessors: tracedDraft,
|
tracedProcessors: tracedDraft,
|
||||||
routeRecording: routeRecordingDraft,
|
routeRecording: routeRecordingDraft,
|
||||||
|
sensitiveKeys: sensitiveKeysDraft.length > 0 ? sensitiveKeysDraft : undefined,
|
||||||
} as ApplicationConfig;
|
} as ApplicationConfig;
|
||||||
updateConfig.mutate({ config: updated, environment: selectedEnv }, {
|
updateConfig.mutate({ config: updated, environment: selectedEnv }, {
|
||||||
onSuccess: (saved: ConfigUpdateResponse) => {
|
onSuccess: (saved: ConfigUpdateResponse) => {
|
||||||
@@ -468,6 +473,76 @@ export default function AppConfigDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) · {(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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
ui/src/pages/Admin/SensitiveKeysPage.module.css
Normal file
39
ui/src/pages/Admin/SensitiveKeysPage.module.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
116
ui/src/pages/Admin/SensitiveKeysPage.tsx
Normal file
116
ui/src/pages/Admin/SensitiveKeysPage.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
|||||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||||
|
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
|
||||||
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
||||||
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||||
|
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
||||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||||
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
||||||
|
|||||||
Reference in New Issue
Block a user