refactor: consolidate oidc_config and admin_thresholds into generic server_config table
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 42s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 34s
CI / build (pull_request) Successful in 1m23s
CI / cleanup-branch (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped

Single JSONB key-value table replaces two singleton config tables, making
future config types trivial to add. Also fixes pre-existing IT failures:
Flyway URL not overridden by Testcontainers, threshold test ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 11:16:31 +01:00
parent 5a0a915cc6
commit 0fcbe83cc2
6 changed files with 77 additions and 30 deletions

View File

@@ -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 - Maintains agent instance registry with states: LIVE → STALE → DEAD
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search - 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 - 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` - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
## CI/CD & Deployment ## 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` - 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` - 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 - 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.

View File

@@ -2,10 +2,11 @@ package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository; 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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -13,47 +14,49 @@ import java.util.Optional;
public class PostgresOidcConfigRepository implements OidcConfigRepository { public class PostgresOidcConfigRepository implements OidcConfigRepository {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public PostgresOidcConfigRepository(JdbcTemplate jdbc) { public PostgresOidcConfigRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.objectMapper = objectMapper;
} }
@Override @Override
public Optional<OidcConfig> find() { public Optional<OidcConfig> find() {
var results = jdbc.query( List<OidcConfig> results = jdbc.query(
"SELECT * FROM oidc_config WHERE config_id = 'default'", "SELECT config_val FROM server_config WHERE config_key = 'oidc'",
(rs, rowNum) -> { (rs, rowNum) -> {
Array arr = rs.getArray("default_roles"); String json = rs.getString("config_val");
String[] roles = arr != null ? (String[]) arr.getArray() : new String[0]; try {
return new OidcConfig( return objectMapper.readValue(json, OidcConfig.class);
rs.getBoolean("enabled"), rs.getString("issuer_uri"), } catch (JsonProcessingException e) {
rs.getString("client_id"), rs.getString("client_secret"), throw new RuntimeException("Failed to deserialize OIDC config", e);
rs.getString("roles_claim"), List.of(roles), }
rs.getBoolean("auto_signup"), rs.getString("display_name_claim"));
}); });
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
} }
@Override @Override
public void save(OidcConfig config) { 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(""" jdbc.update("""
INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, INSERT INTO server_config (config_key, config_val, updated_at)
roles_claim, default_roles, auto_signup, display_name_claim, updated_at) VALUES ('oidc', ?::jsonb, now())
VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, now()) ON CONFLICT (config_key) DO UPDATE SET
ON CONFLICT (config_id) DO UPDATE SET config_val = EXCLUDED.config_val,
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,
updated_at = now() updated_at = now()
""", """,
config.enabled(), config.issuerUri(), config.clientId(), config.clientSecret(), json);
config.rolesClaim(), config.defaultRoles().toArray(new String[0]),
config.autoSignup(), config.displayNameClaim());
} }
@Override @Override
public void delete() { public void delete() {
jdbc.update("DELETE FROM oidc_config WHERE config_id = 'default'"); jdbc.update("DELETE FROM server_config WHERE config_key = 'oidc'");
} }
} }

View File

@@ -24,9 +24,9 @@ public class PostgresThresholdRepository implements ThresholdRepository {
@Override @Override
public Optional<ThresholdConfig> find() { public Optional<ThresholdConfig> find() {
List<ThresholdConfig> results = jdbc.query( List<ThresholdConfig> results = jdbc.query(
"SELECT config FROM admin_thresholds WHERE id = 1", "SELECT config_val FROM server_config WHERE config_key = 'thresholds'",
(rs, rowNum) -> { (rs, rowNum) -> {
String json = rs.getString("config"); String json = rs.getString("config_val");
try { try {
return objectMapper.readValue(json, ThresholdConfig.class); return objectMapper.readValue(json, ThresholdConfig.class);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
@@ -46,10 +46,10 @@ public class PostgresThresholdRepository implements ThresholdRepository {
} }
jdbc.update(""" jdbc.update("""
INSERT INTO admin_thresholds (id, config, updated_by, updated_at) INSERT INTO server_config (config_key, config_val, updated_by, updated_at)
VALUES (1, ?::jsonb, ?, now()) VALUES ('thresholds', ?::jsonb, ?, now())
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (config_key) DO UPDATE SET
config = EXCLUDED.config, config_val = EXCLUDED.config_val,
updated_by = EXCLUDED.updated_by, updated_by = EXCLUDED.updated_by,
updated_at = now() updated_at = now()
""", """,

View File

@@ -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;

View File

@@ -42,6 +42,9 @@ public abstract class AbstractPostgresIT {
registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.flyway.enabled", () -> "true"); 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); registry.add("opensearch.url", opensearch::getHttpHostAddress);
} }
} }

View File

@@ -33,6 +33,7 @@ class ThresholdAdminControllerIT extends AbstractPostgresIT {
void setUp() { void setUp() {
adminJwt = securityHelper.adminToken(); adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'thresholds'");
} }
@Test @Test