diff --git a/CLAUDE.md b/CLAUDE.md index fc67c3f0..1a4c6a00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Maintains agent instance registry with states: LIVE → STALE → DEAD - Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration -- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`oidc_config` table) +- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table) - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` ## CI/CD & Deployment @@ -56,3 +56,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials` - K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health` - Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility + +## Disabled Skills + +- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands. diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java index 3bd0affd..ed5a9753 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java @@ -72,13 +72,14 @@ public class DatabaseAdminController { @Operation(summary = "Get table sizes and row counts") public ResponseEntity> getTables() { var tables = jdbc.query(""" - SELECT schemaname || '.' || relname AS table_name, + SELECT relname AS table_name, n_live_tup AS row_count, pg_size_pretty(pg_total_relation_size(relid)) AS data_size, pg_total_relation_size(relid) AS data_size_bytes, pg_size_pretty(pg_indexes_size(relid)) AS index_size, pg_indexes_size(relid) AS index_size_bytes FROM pg_stat_user_tables + WHERE schemaname = current_schema() ORDER BY pg_total_relation_size(relid) DESC """, (rs, row) -> new TableSizeResponse( rs.getString("table_name"), rs.getLong("row_count"), @@ -94,7 +95,7 @@ public class DatabaseAdminController { SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds, state, query FROM pg_stat_activity - WHERE state != 'idle' AND pid != pg_backend_pid() + WHERE state != 'idle' AND pid != pg_backend_pid() AND datname = current_database() ORDER BY query_start ASC """, (rs, row) -> new ActiveQueryResponse( rs.getInt("pid"), rs.getDouble("duration_seconds"), diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java index f4f13ff4..a7ff7fc2 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OpenSearchAdminController.java @@ -48,17 +48,20 @@ public class OpenSearchAdminController { private final AuditService auditService; private final ObjectMapper objectMapper; private final String opensearchUrl; + private final String indexPrefix; public OpenSearchAdminController(OpenSearchClient client, RestClient restClient, SearchIndexerStats indexerStats, AuditService auditService, ObjectMapper objectMapper, - @Value("${opensearch.url:http://localhost:9200}") String opensearchUrl) { + @Value("${opensearch.url:http://localhost:9200}") String opensearchUrl, + @Value("${opensearch.index-prefix:executions-}") String indexPrefix) { this.client = client; this.restClient = restClient; this.indexerStats = indexerStats; this.auditService = auditService; this.objectMapper = objectMapper; this.opensearchUrl = opensearchUrl; + this.indexPrefix = indexPrefix; } @GetMapping("/status") @@ -109,6 +112,9 @@ public class OpenSearchAdminController { List allIndices = new ArrayList<>(); for (JsonNode idx : indices) { String name = idx.path("index").asText(""); + if (!name.startsWith(indexPrefix)) { + continue; + } if (!search.isEmpty() && !name.contains(search)) { continue; } @@ -146,6 +152,9 @@ public class OpenSearchAdminController { @Operation(summary = "Delete an OpenSearch index") public ResponseEntity deleteIndex(@PathVariable String name, HttpServletRequest request) { try { + if (!name.startsWith(indexPrefix)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete index outside application scope"); + } boolean exists = client.indices().exists(r -> r.index(name)).value(); if (!exists) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresOidcConfigRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresOidcConfigRepository.java index 6da18993..d711d2f8 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresOidcConfigRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresOidcConfigRepository.java @@ -2,10 +2,11 @@ package com.cameleer3.server.app.storage; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; +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.sql.Array; import java.util.List; import java.util.Optional; @@ -13,47 +14,49 @@ import java.util.Optional; public class PostgresOidcConfigRepository implements OidcConfigRepository { private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; - public PostgresOidcConfigRepository(JdbcTemplate jdbc) { + public PostgresOidcConfigRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { this.jdbc = jdbc; + this.objectMapper = objectMapper; } @Override public Optional find() { - var results = jdbc.query( - "SELECT * FROM oidc_config WHERE config_id = 'default'", + List results = jdbc.query( + "SELECT config_val FROM server_config WHERE config_key = 'oidc'", (rs, rowNum) -> { - Array arr = rs.getArray("default_roles"); - String[] roles = arr != null ? (String[]) arr.getArray() : new String[0]; - return new OidcConfig( - rs.getBoolean("enabled"), rs.getString("issuer_uri"), - rs.getString("client_id"), rs.getString("client_secret"), - rs.getString("roles_claim"), List.of(roles), - rs.getBoolean("auto_signup"), rs.getString("display_name_claim")); + String json = rs.getString("config_val"); + try { + return objectMapper.readValue(json, OidcConfig.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to deserialize OIDC config", e); + } }); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @Override public void save(OidcConfig config) { + String json; + try { + json = objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize OIDC config", e); + } + jdbc.update(""" - INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, - roles_claim, default_roles, auto_signup, display_name_claim, updated_at) - VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, now()) - ON CONFLICT (config_id) DO UPDATE SET - enabled = EXCLUDED.enabled, issuer_uri = EXCLUDED.issuer_uri, - client_id = EXCLUDED.client_id, client_secret = EXCLUDED.client_secret, - roles_claim = EXCLUDED.roles_claim, default_roles = EXCLUDED.default_roles, - auto_signup = EXCLUDED.auto_signup, display_name_claim = EXCLUDED.display_name_claim, + INSERT INTO server_config (config_key, config_val, updated_at) + VALUES ('oidc', ?::jsonb, now()) + ON CONFLICT (config_key) DO UPDATE SET + config_val = EXCLUDED.config_val, updated_at = now() """, - config.enabled(), config.issuerUri(), config.clientId(), config.clientSecret(), - config.rolesClaim(), config.defaultRoles().toArray(new String[0]), - config.autoSignup(), config.displayNameClaim()); + json); } @Override public void delete() { - jdbc.update("DELETE FROM oidc_config WHERE config_id = 'default'"); + jdbc.update("DELETE FROM server_config WHERE config_key = 'oidc'"); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java index 0a9f9606..fc81957a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java @@ -24,9 +24,9 @@ public class PostgresThresholdRepository implements ThresholdRepository { @Override public Optional find() { List results = jdbc.query( - "SELECT config FROM admin_thresholds WHERE id = 1", + "SELECT config_val FROM server_config WHERE config_key = 'thresholds'", (rs, rowNum) -> { - String json = rs.getString("config"); + String json = rs.getString("config_val"); try { return objectMapper.readValue(json, ThresholdConfig.class); } catch (JsonProcessingException e) { @@ -46,10 +46,10 @@ public class PostgresThresholdRepository implements ThresholdRepository { } jdbc.update(""" - INSERT INTO admin_thresholds (id, config, updated_by, updated_at) - VALUES (1, ?::jsonb, ?, now()) - ON CONFLICT (id) DO UPDATE SET - config = EXCLUDED.config, + INSERT INTO server_config (config_key, config_val, updated_by, updated_at) + VALUES ('thresholds', ?::jsonb, ?, now()) + ON CONFLICT (config_key) DO UPDATE SET + config_val = EXCLUDED.config_val, updated_by = EXCLUDED.updated_by, updated_at = now() """, diff --git a/cameleer3-server-app/src/main/resources/db/migration/V4__server_config.sql b/cameleer3-server-app/src/main/resources/db/migration/V4__server_config.sql new file mode 100644 index 00000000..88cc3e3a --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V4__server_config.sql @@ -0,0 +1,36 @@ +-- ============================================================= +-- Consolidate oidc_config + admin_thresholds → server_config +-- ============================================================= + +CREATE TABLE server_config ( + config_key TEXT PRIMARY KEY, + config_val JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT +); + +-- Migrate existing oidc_config row (if any) +INSERT INTO server_config (config_key, config_val, updated_at) +SELECT 'oidc', + jsonb_build_object( + 'enabled', enabled, + 'issuerUri', issuer_uri, + 'clientId', client_id, + 'clientSecret', client_secret, + 'rolesClaim', roles_claim, + 'defaultRoles', to_jsonb(default_roles), + 'autoSignup', auto_signup, + 'displayNameClaim', display_name_claim + ), + updated_at +FROM oidc_config +WHERE config_id = 'default'; + +-- Migrate existing admin_thresholds row (if any) +INSERT INTO server_config (config_key, config_val, updated_at, updated_by) +SELECT 'thresholds', config, updated_at, updated_by +FROM admin_thresholds +WHERE id = 1; + +DROP TABLE oidc_config; +DROP TABLE admin_thresholds; diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractPostgresIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractPostgresIT.java index 40962efd..d9c38f83 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractPostgresIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractPostgresIT.java @@ -42,6 +42,9 @@ public abstract class AbstractPostgresIT { registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); registry.add("spring.flyway.enabled", () -> "true"); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); registry.add("opensearch.url", opensearch::getHttpHostAddress); } } diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java index 11c4aef7..0329e880 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java @@ -33,6 +33,7 @@ class ThresholdAdminControllerIT extends AbstractPostgresIT { void setUp() { adminJwt = securityHelper.adminToken(); viewerJwt = securityHelper.viewerToken(); + jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'thresholds'"); } @Test