Merge pull request 'fix/admin-scope-filtering' (#88) from fix/admin-scope-filtering into main
Reviewed-on: cameleer/cameleer3-server#88
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -72,13 +72,14 @@ public class DatabaseAdminController {
|
||||
@Operation(summary = "Get table sizes and row counts")
|
||||
public ResponseEntity<List<TableSizeResponse>> 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"),
|
||||
|
||||
@@ -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<IndexInfoResponse> 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<Void> 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);
|
||||
|
||||
@@ -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<OidcConfig> find() {
|
||||
var results = jdbc.query(
|
||||
"SELECT * FROM oidc_config WHERE config_id = 'default'",
|
||||
List<OidcConfig> 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'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ public class PostgresThresholdRepository implements ThresholdRepository {
|
||||
@Override
|
||||
public Optional<ThresholdConfig> find() {
|
||||
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) -> {
|
||||
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()
|
||||
""",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user