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
|
- 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.
|
||||||
|
|||||||
@@ -72,13 +72,14 @@ public class DatabaseAdminController {
|
|||||||
@Operation(summary = "Get table sizes and row counts")
|
@Operation(summary = "Get table sizes and row counts")
|
||||||
public ResponseEntity<List<TableSizeResponse>> getTables() {
|
public ResponseEntity<List<TableSizeResponse>> getTables() {
|
||||||
var tables = jdbc.query("""
|
var tables = jdbc.query("""
|
||||||
SELECT schemaname || '.' || relname AS table_name,
|
SELECT relname AS table_name,
|
||||||
n_live_tup AS row_count,
|
n_live_tup AS row_count,
|
||||||
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
|
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
|
||||||
pg_total_relation_size(relid) AS data_size_bytes,
|
pg_total_relation_size(relid) AS data_size_bytes,
|
||||||
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
|
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
|
||||||
pg_indexes_size(relid) AS index_size_bytes
|
pg_indexes_size(relid) AS index_size_bytes
|
||||||
FROM pg_stat_user_tables
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname = current_schema()
|
||||||
ORDER BY pg_total_relation_size(relid) DESC
|
ORDER BY pg_total_relation_size(relid) DESC
|
||||||
""", (rs, row) -> new TableSizeResponse(
|
""", (rs, row) -> new TableSizeResponse(
|
||||||
rs.getString("table_name"), rs.getLong("row_count"),
|
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,
|
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
|
||||||
state, query
|
state, query
|
||||||
FROM pg_stat_activity
|
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
|
ORDER BY query_start ASC
|
||||||
""", (rs, row) -> new ActiveQueryResponse(
|
""", (rs, row) -> new ActiveQueryResponse(
|
||||||
rs.getInt("pid"), rs.getDouble("duration_seconds"),
|
rs.getInt("pid"), rs.getDouble("duration_seconds"),
|
||||||
|
|||||||
@@ -48,17 +48,20 @@ public class OpenSearchAdminController {
|
|||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final String opensearchUrl;
|
private final String opensearchUrl;
|
||||||
|
private final String indexPrefix;
|
||||||
|
|
||||||
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
|
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
|
||||||
SearchIndexerStats indexerStats, AuditService auditService,
|
SearchIndexerStats indexerStats, AuditService auditService,
|
||||||
ObjectMapper objectMapper,
|
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.client = client;
|
||||||
this.restClient = restClient;
|
this.restClient = restClient;
|
||||||
this.indexerStats = indexerStats;
|
this.indexerStats = indexerStats;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.opensearchUrl = opensearchUrl;
|
this.opensearchUrl = opensearchUrl;
|
||||||
|
this.indexPrefix = indexPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@@ -109,6 +112,9 @@ public class OpenSearchAdminController {
|
|||||||
List<IndexInfoResponse> allIndices = new ArrayList<>();
|
List<IndexInfoResponse> allIndices = new ArrayList<>();
|
||||||
for (JsonNode idx : indices) {
|
for (JsonNode idx : indices) {
|
||||||
String name = idx.path("index").asText("");
|
String name = idx.path("index").asText("");
|
||||||
|
if (!name.startsWith(indexPrefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!search.isEmpty() && !name.contains(search)) {
|
if (!search.isEmpty() && !name.contains(search)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -146,6 +152,9 @@ public class OpenSearchAdminController {
|
|||||||
@Operation(summary = "Delete an OpenSearch index")
|
@Operation(summary = "Delete an OpenSearch index")
|
||||||
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
|
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
|
||||||
try {
|
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();
|
boolean exists = client.indices().exists(r -> r.index(name)).value();
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name);
|
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.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'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user