Compare commits
73 Commits
ecd76bda97
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dafd7adb00 | ||
|
|
44eecfa5cd | ||
|
|
ff76751629 | ||
|
|
413839452c | ||
|
|
c33e899be7 | ||
|
|
180514a039 | ||
|
|
60fced56ed | ||
|
|
515c942623 | ||
|
|
3ccd4b6548 | ||
|
|
dad608e3a2 | ||
|
|
7479dd6daf | ||
|
|
e4dff0cad1 | ||
|
|
717367252c | ||
|
|
a06808a2a2 | ||
|
|
6b750df1c4 | ||
|
|
ea56bcf2d7 | ||
|
|
826466aa55 | ||
|
|
6a5dba4eba | ||
|
|
8ad0016a8e | ||
|
|
3c226de62f | ||
|
|
c8c62a98bb | ||
|
|
2ae2871822 | ||
|
|
a950feaef1 | ||
|
|
695969d759 | ||
|
|
a72b0954db | ||
|
|
4572230c9c | ||
|
|
752d7ec0e7 | ||
|
|
9ab38dfc59 | ||
|
|
907bcd5017 | ||
|
|
83caf4be5b | ||
|
|
1533bea2a6 | ||
|
|
94d1e81852 | ||
|
|
8e27f45a2b | ||
|
|
a86f56f588 | ||
|
|
651cf9de6e | ||
|
|
63d8078688 | ||
|
|
ee69dbedfc | ||
|
|
313d871948 | ||
|
|
f4d2693561 | ||
|
|
2051572ee2 | ||
|
|
cc433b4215 | ||
|
|
31b60c4e24 | ||
|
|
017a0c218e | ||
|
|
4ff01681d4 | ||
|
|
f2744e3094 | ||
|
|
ea5b5a685d | ||
|
|
045d9ea890 | ||
|
|
9613bddc60 | ||
|
|
2b111c603c | ||
|
|
82124c3145 | ||
|
|
17ef48e392 | ||
| 4085f42160 | |||
|
|
0fcbe83cc2 | ||
|
|
5a0a915cc6 | ||
| f01487ccb4 | |||
|
|
033cfcf5fc | ||
|
|
6d650cdf34 | ||
|
|
6f5b5b8655 | ||
|
|
653ef958ed | ||
|
|
48b17f83a3 | ||
|
|
9d08e74913 | ||
|
|
f42e6279e6 | ||
|
|
d025919f8d | ||
|
|
db6143f9da | ||
|
|
4821ddebba | ||
|
|
65001e0ed0 | ||
|
|
1881aca0e4 | ||
|
|
4842507ff3 | ||
|
|
708aae720c | ||
|
|
ebe97bd386 | ||
|
|
01295c84d8 | ||
|
|
eb0cc8c141 | ||
|
|
b06b3f52a8 |
@@ -120,6 +120,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
-f ui/Dockerfile \
|
-f ui/Dockerfile \
|
||||||
|
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||||
$TAGS \
|
$TAGS \
|
||||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
||||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
package com.cameleer3.server.app.agent;
|
package com.cameleer3.server.app.agent;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
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 org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Periodic task that checks agent lifecycle and expires old commands.
|
* Periodic task that checks agent lifecycle and expires old commands.
|
||||||
* <p>
|
* <p>
|
||||||
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
||||||
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
||||||
* expired pending commands.
|
* expired pending commands. Records lifecycle events for state transitions.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class AgentLifecycleMonitor {
|
public class AgentLifecycleMonitor {
|
||||||
@@ -19,18 +25,46 @@ public class AgentLifecycleMonitor {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
||||||
|
|
||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
|
||||||
public AgentLifecycleMonitor(AgentRegistryService registryService) {
|
public AgentLifecycleMonitor(AgentRegistryService registryService,
|
||||||
|
AgentEventService agentEventService) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
||||||
public void checkLifecycle() {
|
public void checkLifecycle() {
|
||||||
try {
|
try {
|
||||||
|
// Snapshot states before lifecycle check
|
||||||
|
Map<String, AgentState> statesBefore = new HashMap<>();
|
||||||
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
|
statesBefore.put(agent.id(), agent.state());
|
||||||
|
}
|
||||||
|
|
||||||
registryService.checkLifecycle();
|
registryService.checkLifecycle();
|
||||||
registryService.expireOldCommands();
|
registryService.expireOldCommands();
|
||||||
|
|
||||||
|
// Detect transitions and record events
|
||||||
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
|
AgentState before = statesBefore.get(agent.id());
|
||||||
|
if (before != null && before != agent.state()) {
|
||||||
|
String eventType = mapTransitionEvent(before, agent.state());
|
||||||
|
if (eventType != null) {
|
||||||
|
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
|
||||||
|
agent.name() + " " + before + " -> " + agent.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error during agent lifecycle check", e);
|
log.error("Error during agent lifecycle check", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String mapTransitionEvent(AgentState from, AgentState to) {
|
||||||
|
if (from == AgentState.LIVE && to == AgentState.STALE) return "WENT_STALE";
|
||||||
|
if (from == AgentState.STALE && to == AgentState.DEAD) return "WENT_DEAD";
|
||||||
|
if (from == AgentState.STALE && to == AgentState.LIVE) return "RECOVERED";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the {@link AgentRegistryService} bean.
|
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
|
||||||
* <p>
|
* <p>
|
||||||
* Follows the established pattern: core module plain class, app module bean config.
|
* Follows the established pattern: core module plain class, app module bean config.
|
||||||
*/
|
*/
|
||||||
@@ -20,4 +22,9 @@ public class AgentRegistryBeanConfig {
|
|||||||
config.getCommandExpiryMs()
|
config.getCommandExpiryMs()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AgentEventService agentEventService(AgentEventRepository repository) {
|
||||||
|
return new AgentEventService(repository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ public class OpenApiConfig {
|
|||||||
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
||||||
"StatsTimeseries", "TimeseriesBucket",
|
"StatsTimeseries", "TimeseriesBucket",
|
||||||
"SearchResultExecutionSummary", "UserInfo",
|
"SearchResultExecutionSummary", "UserInfo",
|
||||||
"ProcessorNode"
|
"ProcessorNode",
|
||||||
|
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||||
|
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
|
||||||
|
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
|
||||||
);
|
);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public class AgentCommandController {
|
|||||||
|
|
||||||
List<AgentInfo> agents = registryService.findAll().stream()
|
List<AgentInfo> agents = registryService.findAll().stream()
|
||||||
.filter(a -> a.state() == AgentState.LIVE)
|
.filter(a -> a.state() == AgentState.LIVE)
|
||||||
.filter(a -> group.equals(a.group()))
|
.filter(a -> group.equals(a.application()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<String> commandIds = new ArrayList<>();
|
List<String> commandIds = new ArrayList<>();
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentEventResponse;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/agents/events-log")
|
||||||
|
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
|
||||||
|
public class AgentEventsController {
|
||||||
|
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
|
||||||
|
public AgentEventsController(AgentEventService agentEventService) {
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Query agent events",
|
||||||
|
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Events returned")
|
||||||
|
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||||
|
@RequestParam(required = false) String appId,
|
||||||
|
@RequestParam(required = false) String agentId,
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
|
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
|
||||||
|
.stream()
|
||||||
|
.map(AgentEventResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentMetricsResponse;
|
||||||
|
import com.cameleer3.server.app.dto.MetricBucket;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/agents/{agentId}/metrics")
|
||||||
|
public class AgentMetricsController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public AgentMetricsController(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public AgentMetricsResponse getMetrics(
|
||||||
|
@PathVariable String agentId,
|
||||||
|
@RequestParam String names,
|
||||||
|
@RequestParam(required = false) Instant from,
|
||||||
|
@RequestParam(required = false) Instant to,
|
||||||
|
@RequestParam(defaultValue = "60") int buckets) {
|
||||||
|
|
||||||
|
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||||
|
if (to == null) to = Instant.now();
|
||||||
|
|
||||||
|
List<String> metricNames = Arrays.asList(names.split(","));
|
||||||
|
long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1);
|
||||||
|
String intervalStr = intervalMs + " milliseconds";
|
||||||
|
|
||||||
|
Map<String, List<MetricBucket>> result = new LinkedHashMap<>();
|
||||||
|
for (String name : metricNames) {
|
||||||
|
result.put(name.trim(), new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket,
|
||||||
|
metric_name,
|
||||||
|
AVG(metric_value) AS avg_value
|
||||||
|
FROM agent_metrics
|
||||||
|
WHERE agent_id = ?
|
||||||
|
AND collected_at >= ? AND collected_at < ?
|
||||||
|
AND metric_name = ANY(?)
|
||||||
|
GROUP BY bucket, metric_name
|
||||||
|
ORDER BY bucket
|
||||||
|
""";
|
||||||
|
|
||||||
|
String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new);
|
||||||
|
jdbc.query(sql, rs -> {
|
||||||
|
String metricName = rs.getString("metric_name");
|
||||||
|
Instant bucket = rs.getTimestamp("bucket").toInstant();
|
||||||
|
double value = rs.getDouble("avg_value");
|
||||||
|
result.computeIfAbsent(metricName, k -> new ArrayList<>())
|
||||||
|
.add(new MetricBucket(bucket, value));
|
||||||
|
}, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
|
||||||
|
|
||||||
|
return new AgentMetricsResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
|||||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
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;
|
||||||
@@ -23,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -31,8 +33,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
||||||
@@ -50,17 +57,23 @@ public class AgentRegistrationController {
|
|||||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final Ed25519SigningService ed25519SigningService;
|
private final Ed25519SigningService ed25519SigningService;
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
public AgentRegistrationController(AgentRegistryService registryService,
|
public AgentRegistrationController(AgentRegistryService registryService,
|
||||||
AgentRegistryConfig config,
|
AgentRegistryConfig config,
|
||||||
BootstrapTokenValidator bootstrapTokenValidator,
|
BootstrapTokenValidator bootstrapTokenValidator,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
Ed25519SigningService ed25519SigningService) {
|
Ed25519SigningService ed25519SigningService,
|
||||||
|
AgentEventService agentEventService,
|
||||||
|
JdbcTemplate jdbc) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.ed25519SigningService = ed25519SigningService;
|
this.ed25519SigningService = ed25519SigningService;
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@@ -89,18 +102,21 @@ public class AgentRegistrationController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
String group = request.group() != null ? request.group() : "default";
|
String application = request.application() != null ? request.application() : "default";
|
||||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||||
|
|
||||||
AgentInfo agent = registryService.register(
|
AgentInfo agent = registryService.register(
|
||||||
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
|
||||||
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
|
||||||
|
|
||||||
|
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
||||||
|
"Agent registered: " + request.name());
|
||||||
|
|
||||||
// Issue JWT tokens with AGENT role
|
// Issue JWT tokens with AGENT role
|
||||||
List<String> roles = List.of("AGENT");
|
List<String> roles = List.of("AGENT");
|
||||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
|
||||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles);
|
String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRegistrationResponse(
|
return ResponseEntity.ok(new AgentRegistrationResponse(
|
||||||
agent.id(),
|
agent.id(),
|
||||||
@@ -150,9 +166,10 @@ public class AgentRegistrationController {
|
|||||||
// Preserve roles from refresh token
|
// Preserve roles from refresh token
|
||||||
List<String> roles = result.roles().isEmpty()
|
List<String> roles = result.roles().isEmpty()
|
||||||
? List.of("AGENT") : result.roles();
|
? List.of("AGENT") : result.roles();
|
||||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
|
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
||||||
|
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken));
|
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/heartbeat")
|
@PostMapping("/{id}/heartbeat")
|
||||||
@@ -170,13 +187,13 @@ public class AgentRegistrationController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all agents",
|
@Operation(summary = "List all agents",
|
||||||
description = "Returns all registered agents, optionally filtered by status and/or group")
|
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
|
||||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
@RequestParam(required = false) String group) {
|
@RequestParam(required = false) String application) {
|
||||||
List<AgentInfo> agents;
|
List<AgentInfo> agents;
|
||||||
|
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
@@ -190,16 +207,59 @@ public class AgentRegistrationController {
|
|||||||
agents = registryService.findAll();
|
agents = registryService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply group filter if specified
|
// Apply application filter if specified
|
||||||
if (group != null && !group.isBlank()) {
|
if (application != null && !application.isBlank()) {
|
||||||
agents = agents.stream()
|
agents = agents.stream()
|
||||||
.filter(a -> group.equals(a.group()))
|
.filter(a -> application.equals(a.application()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AgentInstanceResponse> response = agents.stream()
|
// Enrich with runtime metrics from continuous aggregates
|
||||||
.map(AgentInstanceResponse::from)
|
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||||
|
final List<AgentInfo> finalAgents = agents;
|
||||||
|
|
||||||
|
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||||
|
.map(a -> {
|
||||||
|
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||||
|
double[] m = agentMetrics.get(a.application());
|
||||||
|
if (m != null) {
|
||||||
|
long appAgentCount = finalAgents.stream()
|
||||||
|
.filter(ag -> ag.application().equals(a.application())).count();
|
||||||
|
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||||
|
double errorRate = m[1];
|
||||||
|
int activeRoutes = (int) m[2];
|
||||||
|
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
.toList();
|
.toList();
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, double[]> queryAgentMetrics() {
|
||||||
|
Map<String, double[]> result = new HashMap<>();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_name, " +
|
||||||
|
"SUM(total_count) AS total, " +
|
||||||
|
"SUM(failed_count) AS failed, " +
|
||||||
|
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY application_name",
|
||||||
|
rs -> {
|
||||||
|
long total = rs.getLong("total");
|
||||||
|
long failed = rs.getLong("failed");
|
||||||
|
double tps = total / 60.0;
|
||||||
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
|
int activeRoutes = rs.getInt("active_routes");
|
||||||
|
result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
|
||||||
|
},
|
||||||
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not query agent metrics: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -90,14 +90,14 @@ public class DiagramRenderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Find diagram by application group and route ID",
|
@Operation(summary = "Find diagram by application and route ID",
|
||||||
description = "Resolves group to agent IDs and finds the latest diagram for the route")
|
description = "Resolves application to agent IDs and finds the latest diagram for the route")
|
||||||
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
||||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given group and route")
|
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
||||||
public ResponseEntity<DiagramLayout> findByGroupAndRoute(
|
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
||||||
@RequestParam String group,
|
@RequestParam String application,
|
||||||
@RequestParam String routeId) {
|
@RequestParam String routeId) {
|
||||||
List<String> agentIds = registryService.findByGroup(group).stream()
|
List<String> agentIds = registryService.findByApplication(application).stream()
|
||||||
.map(AgentInfo::id)
|
.map(AgentInfo::id)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ public class ExecutionController {
|
|||||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||||
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
||||||
String agentId = extractAgentId();
|
String agentId = extractAgentId();
|
||||||
String groupName = resolveGroupName(agentId);
|
String applicationName = resolveApplicationName(agentId);
|
||||||
List<RouteExecution> executions = parsePayload(body);
|
List<RouteExecution> executions = parsePayload(body);
|
||||||
|
|
||||||
for (RouteExecution execution : executions) {
|
for (RouteExecution execution : executions) {
|
||||||
ingestionService.ingestExecution(agentId, groupName, execution);
|
ingestionService.ingestExecution(agentId, applicationName, execution);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
@@ -68,9 +68,9 @@ public class ExecutionController {
|
|||||||
return auth != null ? auth.getName() : "";
|
return auth != null ? auth.getName() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveGroupName(String agentId) {
|
private String resolveApplicationName(String agentId) {
|
||||||
AgentInfo agent = registryService.findById(agentId);
|
AgentInfo agent = registryService.findById(agentId);
|
||||||
return agent != null ? agent.group() : "";
|
return agent != null ? agent.application() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
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.rbac.GroupDetail;
|
||||||
|
import com.cameleer3.server.core.rbac.GroupRepository;
|
||||||
|
import com.cameleer3.server.core.rbac.GroupSummary;
|
||||||
|
import com.cameleer3.server.core.rbac.RbacService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
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.RestController;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoints for group management.
|
||||||
|
* Protected by {@code ROLE_ADMIN}.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/groups")
|
||||||
|
@Tag(name = "Group Admin", description = "Group management (ADMIN only)")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public class GroupAdminController {
|
||||||
|
|
||||||
|
private final GroupRepository groupRepository;
|
||||||
|
private final RbacService rbacService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public GroupAdminController(GroupRepository groupRepository, RbacService rbacService,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
this.rbacService = rbacService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all groups with hierarchy and effective roles")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Group list returned")
|
||||||
|
public ResponseEntity<List<GroupDetail>> listGroups() {
|
||||||
|
List<GroupSummary> summaries = groupRepository.findAll();
|
||||||
|
List<GroupDetail> details = new ArrayList<>();
|
||||||
|
for (GroupSummary summary : summaries) {
|
||||||
|
groupRepository.findById(summary.id()).ifPresent(details::add);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get group by ID with effective roles")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Group found")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||||
|
public ResponseEntity<GroupDetail> getGroup(@PathVariable UUID id) {
|
||||||
|
return groupRepository.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new group")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Group created")
|
||||||
|
public ResponseEntity<Map<String, UUID>> createGroup(@RequestBody CreateGroupRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
UUID id = groupRepository.create(request.name(), request.parentGroupId());
|
||||||
|
auditService.log("create_group", AuditCategory.RBAC, id.toString(),
|
||||||
|
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(Map.of("id", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Update group name or parent")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Group updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||||
|
@ApiResponse(responseCode = "409", description = "Cycle detected in group hierarchy")
|
||||||
|
public ResponseEntity<Void> updateGroup(@PathVariable UUID id,
|
||||||
|
@RequestBody UpdateGroupRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
Optional<GroupDetail> existing = groupRepository.findById(id);
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle detection: walk ancestor chain of proposed parent and check if it includes 'id'
|
||||||
|
if (request.parentGroupId() != null) {
|
||||||
|
List<GroupSummary> ancestors = groupRepository.findAncestorChain(request.parentGroupId());
|
||||||
|
for (GroupSummary ancestor : ancestors) {
|
||||||
|
if (ancestor.id().equals(id)) {
|
||||||
|
return ResponseEntity.status(409).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check that the proposed parent itself is not the group being updated
|
||||||
|
if (request.parentGroupId().equals(id)) {
|
||||||
|
return ResponseEntity.status(409).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupRepository.update(id, request.name(), request.parentGroupId());
|
||||||
|
auditService.log("update_group", AuditCategory.RBAC, id.toString(),
|
||||||
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Delete group")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Group deleted")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||||
|
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
if (groupRepository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
groupRepository.delete(id);
|
||||||
|
auditService.log("delete_group", AuditCategory.RBAC, id.toString(),
|
||||||
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/roles/{roleId}")
|
||||||
|
@Operation(summary = "Assign a role to a group")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Role assigned to group")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||||
|
public ResponseEntity<Void> assignRoleToGroup(@PathVariable UUID id,
|
||||||
|
@PathVariable UUID roleId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
if (groupRepository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
groupRepository.addRole(id, roleId);
|
||||||
|
auditService.log("assign_role_to_group", AuditCategory.RBAC, id.toString(),
|
||||||
|
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/roles/{roleId}")
|
||||||
|
@Operation(summary = "Remove a role from a group")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Role removed from group")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||||
|
public ResponseEntity<Void> removeRoleFromGroup(@PathVariable UUID id,
|
||||||
|
@PathVariable UUID roleId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
if (groupRepository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
groupRepository.removeRole(id, roleId);
|
||||||
|
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
|
||||||
|
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateGroupRequest(String name, UUID parentGroupId) {}
|
||||||
|
public record UpdateGroupRequest(String name, UUID parentGroupId) {}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.rbac.RbacService;
|
||||||
|
import com.cameleer3.server.core.rbac.RbacStats;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoint for RBAC statistics.
|
||||||
|
* Protected by {@code ROLE_ADMIN}.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/rbac")
|
||||||
|
@Tag(name = "RBAC Stats", description = "RBAC statistics (ADMIN only)")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public class RbacStatsController {
|
||||||
|
|
||||||
|
private final RbacService rbacService;
|
||||||
|
|
||||||
|
public RbacStatsController(RbacService rbacService) {
|
||||||
|
this.rbacService = rbacService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
@Operation(summary = "Get RBAC statistics for the dashboard")
|
||||||
|
@ApiResponse(responseCode = "200", description = "RBAC stats returned")
|
||||||
|
public ResponseEntity<RbacStats> getStats() {
|
||||||
|
return ResponseEntity.ok(rbacService.getStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
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.rbac.RbacService;
|
||||||
|
import com.cameleer3.server.core.rbac.RoleDetail;
|
||||||
|
import com.cameleer3.server.core.rbac.RoleRepository;
|
||||||
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
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.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin endpoints for role management.
|
||||||
|
* Protected by {@code ROLE_ADMIN}.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/roles")
|
||||||
|
@Tag(name = "Role Admin", description = "Role management (ADMIN only)")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public class RoleAdminController {
|
||||||
|
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
private final RbacService rbacService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public RoleAdminController(RoleRepository roleRepository, RbacService rbacService,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
this.rbacService = rbacService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all roles (system and custom)")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Role list returned")
|
||||||
|
public ResponseEntity<List<RoleDetail>> listRoles() {
|
||||||
|
return ResponseEntity.ok(roleRepository.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get role by ID with effective principals")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Role found")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Role not found")
|
||||||
|
public ResponseEntity<RoleDetail> getRole(@PathVariable UUID id) {
|
||||||
|
return roleRepository.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a custom role")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Role created")
|
||||||
|
public ResponseEntity<Map<String, UUID>> createRole(@RequestBody CreateRoleRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
String desc = request.description() != null ? request.description() : "";
|
||||||
|
String sc = request.scope() != null ? request.scope() : "custom";
|
||||||
|
UUID id = roleRepository.create(request.name(), desc, sc);
|
||||||
|
auditService.log("create_role", AuditCategory.RBAC, id.toString(),
|
||||||
|
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(Map.of("id", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Update a custom role")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Role updated")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Cannot modify system role")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Role not found")
|
||||||
|
public ResponseEntity<Void> updateRole(@PathVariable UUID id,
|
||||||
|
@RequestBody UpdateRoleRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
if (SystemRole.isSystem(id)) {
|
||||||
|
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
|
||||||
|
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
if (roleRepository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
roleRepository.update(id, request.name(), request.description(), request.scope());
|
||||||
|
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
|
||||||
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Delete a custom role")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Role deleted")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Cannot delete system role")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Role not found")
|
||||||
|
public ResponseEntity<Void> deleteRole(@PathVariable UUID id,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
if (SystemRole.isSystem(id)) {
|
||||||
|
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
|
||||||
|
Map.of("reason", "system_role_protected"), AuditResult.FAILURE, httpRequest);
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
if (roleRepository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
roleRepository.delete(id);
|
||||||
|
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
|
||||||
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateRoleRequest(String name, String description, String scope) {}
|
||||||
|
public record UpdateRoleRequest(String name, String description, String scope) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentSummary;
|
||||||
|
import com.cameleer3.server.app.dto.AppCatalogEntry;
|
||||||
|
import com.cameleer3.server.app.dto.RouteSummary;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
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.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/routes")
|
||||||
|
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
||||||
|
public class RouteCatalogController {
|
||||||
|
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/catalog")
|
||||||
|
@Operation(summary = "Get route catalog",
|
||||||
|
description = "Returns all applications with their routes, agents, and health status")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||||
|
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
||||||
|
List<AgentInfo> allAgents = registryService.findAll();
|
||||||
|
|
||||||
|
// Group agents by application name
|
||||||
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
|
.collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
// Collect all distinct routes per app
|
||||||
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
Set<String> routes = new LinkedHashSet<>();
|
||||||
|
for (AgentInfo agent : entry.getValue()) {
|
||||||
|
if (agent.routeIds() != null) {
|
||||||
|
routes.addAll(agent.routeIds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routesByApp.put(entry.getKey(), routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query route-level stats for the last 24 hours
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant from24h = now.minus(24, ChronoUnit.HOURS);
|
||||||
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
|
|
||||||
|
// Route exchange counts from continuous aggregate
|
||||||
|
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||||
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY application_name, route_id",
|
||||||
|
rs -> {
|
||||||
|
String key = rs.getString("application_name") + "/" + rs.getString("route_id");
|
||||||
|
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||||
|
Timestamp ts = rs.getTimestamp("last_seen");
|
||||||
|
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||||
|
},
|
||||||
|
Timestamp.from(from24h), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Continuous aggregate may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-agent TPS from the last minute
|
||||||
|
Map<String, Double> agentTps = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_name, SUM(total_count) AS cnt " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"GROUP BY application_name",
|
||||||
|
rs -> {
|
||||||
|
// This gives per-app TPS; we'll distribute among agents below
|
||||||
|
},
|
||||||
|
Timestamp.from(from1m), Timestamp.from(now));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Continuous aggregate may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build catalog entries
|
||||||
|
List<AppCatalogEntry> catalog = new ArrayList<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
String appId = entry.getKey();
|
||||||
|
List<AgentInfo> agents = entry.getValue();
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||||
|
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||||
|
.map(routeId -> {
|
||||||
|
String key = appId + "/" + routeId;
|
||||||
|
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||||
|
Instant lastSeen = routeLastSeen.get(key);
|
||||||
|
return new RouteSummary(routeId, count, lastSeen);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Agent summaries
|
||||||
|
List<AgentSummary> agentSummaries = agents.stream()
|
||||||
|
.map(a -> new AgentSummary(a.id(), a.name(), a.state().name().toLowerCase(), 0.0))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Health = worst state among agents
|
||||||
|
String health = computeWorstHealth(agents);
|
||||||
|
|
||||||
|
// Total exchange count for the app
|
||||||
|
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||||
|
|
||||||
|
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
|
||||||
|
agents.size(), health, totalExchanges));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||||
|
boolean hasDead = false;
|
||||||
|
boolean hasStale = false;
|
||||||
|
for (AgentInfo a : agents) {
|
||||||
|
if (a.state() == AgentState.DEAD) hasDead = true;
|
||||||
|
if (a.state() == AgentState.STALE) hasStale = true;
|
||||||
|
}
|
||||||
|
if (hasDead) return "dead";
|
||||||
|
if (hasStale) return "stale";
|
||||||
|
return "live";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.ProcessorMetrics;
|
||||||
|
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/routes")
|
||||||
|
@Tag(name = "Route Metrics", description = "Route performance metrics")
|
||||||
|
public class RouteMetricsController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public RouteMetricsController(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/metrics")
|
||||||
|
@Operation(summary = "Get route metrics",
|
||||||
|
description = "Returns aggregated performance metrics per route for the given time window")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
|
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(required = false) String appId) {
|
||||||
|
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
|
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||||
|
|
||||||
|
var sql = new StringBuilder(
|
||||||
|
"SELECT application_name, route_id, " +
|
||||||
|
"SUM(total_count) AS total, " +
|
||||||
|
"SUM(failed_count) AS failed, " +
|
||||||
|
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
||||||
|
"COALESCE(MAX(p99_duration), 0) AS p99_dur " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ?");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
params.add(Timestamp.from(fromInstant));
|
||||||
|
params.add(Timestamp.from(toInstant));
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND application_name = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
|
||||||
|
|
||||||
|
// Key struct for sparkline lookup
|
||||||
|
record RouteKey(String appId, String routeId) {}
|
||||||
|
List<RouteKey> routeKeys = new ArrayList<>();
|
||||||
|
|
||||||
|
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
|
String applicationName = rs.getString("application_name");
|
||||||
|
String routeId = rs.getString("route_id");
|
||||||
|
long total = rs.getLong("total");
|
||||||
|
long failed = rs.getLong("failed");
|
||||||
|
double avgDur = rs.getDouble("avg_dur");
|
||||||
|
double p99Dur = rs.getDouble("p99_dur");
|
||||||
|
|
||||||
|
double successRate = total > 0 ? (double) (total - failed) / total : 1.0;
|
||||||
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
|
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||||
|
|
||||||
|
routeKeys.add(new RouteKey(applicationName, routeId));
|
||||||
|
return new RouteMetrics(routeId, applicationName, total, successRate,
|
||||||
|
avgDur, p99Dur, errorRate, tps, List.of());
|
||||||
|
}, params.toArray());
|
||||||
|
|
||||||
|
// Fetch sparklines (12 buckets over the time window)
|
||||||
|
if (!metrics.isEmpty()) {
|
||||||
|
int sparkBuckets = 12;
|
||||||
|
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
|
||||||
|
|
||||||
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
|
RouteMetrics m = metrics.get(i);
|
||||||
|
try {
|
||||||
|
List<Double> sparkline = jdbc.query(
|
||||||
|
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||||
|
"COALESCE(SUM(total_count), 0) AS cnt " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||||
|
"AND application_name = ? AND route_id = ? " +
|
||||||
|
"GROUP BY period ORDER BY period",
|
||||||
|
(rs, rowNum) -> rs.getDouble("cnt"),
|
||||||
|
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
||||||
|
m.appId(), m.routeId());
|
||||||
|
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||||
|
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||||
|
m.errorRate(), m.throughputPerSec(), sparkline));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Leave sparkline empty on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/metrics/processors")
|
||||||
|
@Operation(summary = "Get processor metrics",
|
||||||
|
description = "Returns aggregated performance metrics per processor for the given route and time window")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
|
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
|
||||||
|
@RequestParam String routeId,
|
||||||
|
@RequestParam(required = false) String appId,
|
||||||
|
@RequestParam(required = false) Instant from,
|
||||||
|
@RequestParam(required = false) Instant to) {
|
||||||
|
|
||||||
|
Instant toInstant = to != null ? to : Instant.now();
|
||||||
|
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
|
|
||||||
|
var sql = new StringBuilder(
|
||||||
|
"SELECT processor_id, processor_type, route_id, application_name, " +
|
||||||
|
"SUM(total_count) AS total_count, " +
|
||||||
|
"SUM(failed_count) AS failed_count, " +
|
||||||
|
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
|
||||||
|
"MAX(p99_duration) AS p99_duration_ms " +
|
||||||
|
"FROM stats_1m_processor_detail " +
|
||||||
|
"WHERE bucket >= ? AND bucket < ? AND route_id = ?");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
params.add(Timestamp.from(fromInstant));
|
||||||
|
params.add(Timestamp.from(toInstant));
|
||||||
|
params.add(routeId);
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND application_name = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
|
||||||
|
sql.append(" ORDER BY SUM(total_count) DESC");
|
||||||
|
|
||||||
|
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
|
long totalCount = rs.getLong("total_count");
|
||||||
|
long failedCount = rs.getLong("failed_count");
|
||||||
|
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
|
||||||
|
return new ProcessorMetrics(
|
||||||
|
rs.getString("processor_id"),
|
||||||
|
rs.getString("processor_type"),
|
||||||
|
rs.getString("route_id"),
|
||||||
|
rs.getString("application_name"),
|
||||||
|
totalCount,
|
||||||
|
failedCount,
|
||||||
|
rs.getDouble("avg_duration_ms"),
|
||||||
|
rs.getDouble("p99_duration_ms"),
|
||||||
|
errorRate);
|
||||||
|
}, params.toArray());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,13 +51,13 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String agentId,
|
@RequestParam(required = false) String agentId,
|
||||||
@RequestParam(required = false) String processorType,
|
@RequestParam(required = false) String processorType,
|
||||||
@RequestParam(required = false) String group,
|
@RequestParam(required = false) String application,
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(required = false) String sortField,
|
@RequestParam(required = false) String sortField,
|
||||||
@RequestParam(required = false) String sortDir) {
|
@RequestParam(required = false) String sortDir) {
|
||||||
|
|
||||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
|
|
||||||
SearchRequest request = new SearchRequest(
|
SearchRequest request = new SearchRequest(
|
||||||
status, timeFrom, timeTo,
|
status, timeFrom, timeTo,
|
||||||
@@ -65,7 +65,7 @@ public class SearchController {
|
|||||||
correlationId,
|
correlationId,
|
||||||
text, null, null, null,
|
text, null, null, null,
|
||||||
routeId, agentId, processorType,
|
routeId, agentId, processorType,
|
||||||
group, agentIds,
|
application, agentIds,
|
||||||
offset, limit,
|
offset, limit,
|
||||||
sortField, sortDir
|
sortField, sortDir
|
||||||
);
|
);
|
||||||
@@ -77,11 +77,11 @@ public class SearchController {
|
|||||||
@Operation(summary = "Advanced search with all filters")
|
@Operation(summary = "Advanced search with all filters")
|
||||||
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
||||||
@RequestBody SearchRequest request) {
|
@RequestBody SearchRequest request) {
|
||||||
// Resolve group to agentIds if group is specified but agentIds is not
|
// Resolve application to agentIds if application is specified but agentIds is not
|
||||||
SearchRequest resolved = request;
|
SearchRequest resolved = request;
|
||||||
if (request.group() != null && !request.group().isBlank()
|
if (request.application() != null && !request.application().isBlank()
|
||||||
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
||||||
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group()));
|
resolved = request.withAgentIds(resolveApplicationToAgentIds(request.application()));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(searchService.search(resolved));
|
return ResponseEntity.ok(searchService.search(resolved));
|
||||||
}
|
}
|
||||||
@@ -92,12 +92,15 @@ public class SearchController {
|
|||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String group) {
|
@RequestParam(required = false) String application) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
if (routeId == null && application == null) {
|
||||||
if (routeId == null && agentIds == null) {
|
|
||||||
return ResponseEntity.ok(searchService.stats(from, end));
|
return ResponseEntity.ok(searchService.stats(from, end));
|
||||||
}
|
}
|
||||||
|
if (routeId == null) {
|
||||||
|
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
|
||||||
|
}
|
||||||
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +111,15 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(defaultValue = "24") int buckets,
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String group) {
|
@RequestParam(required = false) String application) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
if (routeId == null && application == null) {
|
||||||
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||||
|
}
|
||||||
|
if (routeId == null) {
|
||||||
|
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
|
||||||
|
}
|
||||||
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
if (routeId == null && agentIds == null) {
|
if (routeId == null && agentIds == null) {
|
||||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||||
}
|
}
|
||||||
@@ -118,14 +127,14 @@ public class SearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an application group name to agent IDs.
|
* Resolve an application name to agent IDs.
|
||||||
* Returns null if group is null/blank (no filtering).
|
* Returns null if application is null/blank (no filtering).
|
||||||
*/
|
*/
|
||||||
private List<String> resolveGroupToAgentIds(String group) {
|
private List<String> resolveApplicationToAgentIds(String application) {
|
||||||
if (group == null || group.isBlank()) {
|
if (application == null || application.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return registryService.findByGroup(group).stream()
|
return registryService.findByApplication(application).stream()
|
||||||
.map(AgentInfo::id)
|
.map(AgentInfo::id)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.SetPasswordRequest;
|
||||||
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.rbac.RbacService;
|
||||||
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
|
import com.cameleer3.server.core.rbac.UserDetail;
|
||||||
import com.cameleer3.server.core.security.UserInfo;
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
import com.cameleer3.server.core.security.UserRepository;
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin endpoints for user management.
|
* Admin endpoints for user management.
|
||||||
* Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns.
|
* Protected by {@code ROLE_ADMIN}.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/users")
|
@RequestMapping("/api/v1/admin/users")
|
||||||
@@ -32,47 +42,127 @@ import java.util.Map;
|
|||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
public class UserAdminController {
|
public class UserAdminController {
|
||||||
|
|
||||||
|
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
|
private final RbacService rbacService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
public UserAdminController(UserRepository userRepository, AuditService auditService) {
|
public UserAdminController(RbacService rbacService, UserRepository userRepository,
|
||||||
|
AuditService auditService) {
|
||||||
|
this.rbacService = rbacService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all users")
|
@Operation(summary = "List all users with RBAC detail")
|
||||||
@ApiResponse(responseCode = "200", description = "User list returned")
|
@ApiResponse(responseCode = "200", description = "User list returned")
|
||||||
public ResponseEntity<List<UserInfo>> listUsers() {
|
public ResponseEntity<List<UserDetail>> listUsers() {
|
||||||
return ResponseEntity.ok(userRepository.findAll());
|
return ResponseEntity.ok(rbacService.listUsers());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{userId}")
|
@GetMapping("/{userId}")
|
||||||
@Operation(summary = "Get user by ID")
|
@Operation(summary = "Get user by ID with RBAC detail")
|
||||||
@ApiResponse(responseCode = "200", description = "User found")
|
@ApiResponse(responseCode = "200", description = "User found")
|
||||||
@ApiResponse(responseCode = "404", description = "User not found")
|
@ApiResponse(responseCode = "404", description = "User not found")
|
||||||
public ResponseEntity<UserInfo> getUser(@PathVariable String userId) {
|
public ResponseEntity<UserDetail> getUser(@PathVariable String userId) {
|
||||||
return userRepository.findById(userId)
|
UserDetail detail = rbacService.getUser(userId);
|
||||||
.map(ResponseEntity::ok)
|
if (detail == null) {
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{userId}/roles")
|
|
||||||
@Operation(summary = "Update user roles")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Roles updated")
|
|
||||||
@ApiResponse(responseCode = "404", description = "User not found")
|
|
||||||
public ResponseEntity<Void> updateRoles(@PathVariable String userId,
|
|
||||||
@RequestBody RolesRequest request,
|
|
||||||
HttpServletRequest httpRequest) {
|
|
||||||
if (userRepository.findById(userId).isEmpty()) {
|
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
userRepository.updateRoles(userId, request.roles());
|
return ResponseEntity.ok(detail);
|
||||||
auditService.log("update_roles", AuditCategory.USER_MGMT, userId,
|
}
|
||||||
Map.of("roles", request.roles()), AuditResult.SUCCESS, httpRequest);
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a local user")
|
||||||
|
@ApiResponse(responseCode = "200", description = "User created")
|
||||||
|
public ResponseEntity<UserDetail> createUser(@RequestBody CreateUserRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
String userId = "user:" + request.username();
|
||||||
|
UserInfo user = new UserInfo(userId, "local",
|
||||||
|
request.email() != null ? request.email() : "",
|
||||||
|
request.displayName() != null ? request.displayName() : request.username(),
|
||||||
|
Instant.now());
|
||||||
|
userRepository.upsert(user);
|
||||||
|
if (request.password() != null && !request.password().isBlank()) {
|
||||||
|
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||||
|
}
|
||||||
|
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
|
||||||
|
auditService.log("create_user", AuditCategory.USER_MGMT, userId,
|
||||||
|
Map.of("username", request.username()), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(rbacService.getUser(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{userId}")
|
||||||
|
@Operation(summary = "Update user display name or email")
|
||||||
|
@ApiResponse(responseCode = "200", description = "User updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "User not found")
|
||||||
|
public ResponseEntity<Void> updateUser(@PathVariable String userId,
|
||||||
|
@RequestBody UpdateUserRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
var existing = userRepository.findById(userId);
|
||||||
|
if (existing.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
var user = existing.get();
|
||||||
|
var updated = new UserInfo(user.userId(), user.provider(),
|
||||||
|
request.email() != null ? request.email() : user.email(),
|
||||||
|
request.displayName() != null ? request.displayName() : user.displayName(),
|
||||||
|
user.createdAt());
|
||||||
|
userRepository.upsert(updated);
|
||||||
|
auditService.log("update_user", AuditCategory.USER_MGMT, userId,
|
||||||
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{userId}/roles/{roleId}")
|
||||||
|
@Operation(summary = "Assign a role to a user")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Role assigned")
|
||||||
|
@ApiResponse(responseCode = "404", description = "User or role not found")
|
||||||
|
public ResponseEntity<Void> assignRoleToUser(@PathVariable String userId,
|
||||||
|
@PathVariable UUID roleId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
rbacService.assignRoleToUser(userId, roleId);
|
||||||
|
auditService.log("assign_role_to_user", AuditCategory.USER_MGMT, userId,
|
||||||
|
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{userId}/roles/{roleId}")
|
||||||
|
@Operation(summary = "Remove a role from a user")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Role removed")
|
||||||
|
public ResponseEntity<Void> removeRoleFromUser(@PathVariable String userId,
|
||||||
|
@PathVariable UUID roleId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
rbacService.removeRoleFromUser(userId, roleId);
|
||||||
|
auditService.log("remove_role_from_user", AuditCategory.USER_MGMT, userId,
|
||||||
|
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{userId}/groups/{groupId}")
|
||||||
|
@Operation(summary = "Add a user to a group")
|
||||||
|
@ApiResponse(responseCode = "200", description = "User added to group")
|
||||||
|
public ResponseEntity<Void> addUserToGroup(@PathVariable String userId,
|
||||||
|
@PathVariable UUID groupId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
rbacService.addUserToGroup(userId, groupId);
|
||||||
|
auditService.log("add_user_to_group", AuditCategory.USER_MGMT, userId,
|
||||||
|
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{userId}/groups/{groupId}")
|
||||||
|
@Operation(summary = "Remove a user from a group")
|
||||||
|
@ApiResponse(responseCode = "204", description = "User removed from group")
|
||||||
|
public ResponseEntity<Void> removeUserFromGroup(@PathVariable String userId,
|
||||||
|
@PathVariable UUID groupId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
rbacService.removeUserFromGroup(userId, groupId);
|
||||||
|
auditService.log("remove_user_from_group", AuditCategory.USER_MGMT, userId,
|
||||||
|
Map.of("groupId", groupId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{userId}")
|
@DeleteMapping("/{userId}")
|
||||||
@Operation(summary = "Delete user")
|
@Operation(summary = "Delete user")
|
||||||
@ApiResponse(responseCode = "204", description = "User deleted")
|
@ApiResponse(responseCode = "204", description = "User deleted")
|
||||||
@@ -84,5 +174,18 @@ public class UserAdminController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RolesRequest(List<String> roles) {}
|
@PostMapping("/{userId}/password")
|
||||||
|
@Operation(summary = "Reset user password")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Password reset")
|
||||||
|
public ResponseEntity<Void> resetPassword(
|
||||||
|
@PathVariable String userId,
|
||||||
|
@Valid @RequestBody SetPasswordRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||||
|
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateUserRequest(String username, String displayName, String email, String password) {}
|
||||||
|
public record UpdateUserRequest(String displayName, String email) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Schema(description = "Agent lifecycle event")
|
||||||
|
public record AgentEventResponse(
|
||||||
|
@NotNull long id,
|
||||||
|
@NotNull String agentId,
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull String eventType,
|
||||||
|
String detail,
|
||||||
|
@NotNull Instant timestamp
|
||||||
|
) {
|
||||||
|
public static AgentEventResponse from(AgentEventRecord record) {
|
||||||
|
return new AgentEventResponse(
|
||||||
|
record.id(), record.agentId(), record.appId(),
|
||||||
|
record.eventType(), record.detail(), record.timestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,24 +4,46 @@ import com.cameleer3.server.core.agent.AgentInfo;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Schema(description = "Agent instance summary")
|
@Schema(description = "Agent instance summary with runtime metrics")
|
||||||
public record AgentInstanceResponse(
|
public record AgentInstanceResponse(
|
||||||
@NotNull String id,
|
@NotNull String id,
|
||||||
@NotNull String name,
|
@NotNull String name,
|
||||||
@NotNull String group,
|
@NotNull String application,
|
||||||
@NotNull String status,
|
@NotNull String status,
|
||||||
@NotNull List<String> routeIds,
|
@NotNull List<String> routeIds,
|
||||||
@NotNull Instant registeredAt,
|
@NotNull Instant registeredAt,
|
||||||
@NotNull Instant lastHeartbeat
|
@NotNull Instant lastHeartbeat,
|
||||||
|
String version,
|
||||||
|
Map<String, Object> capabilities,
|
||||||
|
double tps,
|
||||||
|
double errorRate,
|
||||||
|
int activeRoutes,
|
||||||
|
int totalRoutes,
|
||||||
|
long uptimeSeconds
|
||||||
) {
|
) {
|
||||||
public static AgentInstanceResponse from(AgentInfo info) {
|
public static AgentInstanceResponse from(AgentInfo info) {
|
||||||
|
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||||
return new AgentInstanceResponse(
|
return new AgentInstanceResponse(
|
||||||
info.id(), info.name(), info.group(),
|
info.id(), info.name(), info.application(),
|
||||||
info.state().name(), info.routeIds(),
|
info.state().name(), info.routeIds(),
|
||||||
info.registeredAt(), info.lastHeartbeat()
|
info.registeredAt(), info.lastHeartbeat(),
|
||||||
|
info.version(), info.capabilities(),
|
||||||
|
0.0, 0.0,
|
||||||
|
0, info.routeIds() != null ? info.routeIds().size() : 0,
|
||||||
|
uptime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||||
|
return new AgentInstanceResponse(
|
||||||
|
id, name, application, status, routeIds, registeredAt, lastHeartbeat,
|
||||||
|
version, capabilities,
|
||||||
|
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AgentMetricsResponse(
|
||||||
|
@NotNull Map<String, List<MetricBucket>> metrics
|
||||||
|
) {}
|
||||||
@@ -3,5 +3,5 @@ package com.cameleer3.server.app.dto;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@Schema(description = "Refreshed access token")
|
@Schema(description = "Refreshed access and refresh tokens")
|
||||||
public record AgentRefreshResponse(@NotNull String accessToken) {}
|
public record AgentRefreshResponse(@NotNull String accessToken, @NotNull String refreshToken) {}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import java.util.Map;
|
|||||||
public record AgentRegistrationRequest(
|
public record AgentRegistrationRequest(
|
||||||
@NotNull String agentId,
|
@NotNull String agentId,
|
||||||
@NotNull String name,
|
@NotNull String name,
|
||||||
@Schema(defaultValue = "default") String group,
|
@Schema(defaultValue = "default") String application,
|
||||||
String version,
|
String version,
|
||||||
List<String> routeIds,
|
List<String> routeIds,
|
||||||
Map<String, Object> capabilities
|
Map<String, Object> capabilities
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Schema(description = "Summary of an agent instance for sidebar display")
|
||||||
|
public record AgentSummary(
|
||||||
|
@NotNull String id,
|
||||||
|
@NotNull String name,
|
||||||
|
@NotNull String status,
|
||||||
|
@NotNull double tps
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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 = "Application catalog entry with routes and agents")
|
||||||
|
public record AppCatalogEntry(
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull List<RouteSummary> routes,
|
||||||
|
@NotNull List<AgentSummary> agents,
|
||||||
|
@NotNull int agentCount,
|
||||||
|
@NotNull String health,
|
||||||
|
@NotNull long exchangeCount
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record MetricBucket(
|
||||||
|
@NotNull Instant time,
|
||||||
|
double value
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record ProcessorMetrics(
|
||||||
|
@NotNull String processorId,
|
||||||
|
@NotNull String processorType,
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull String appId,
|
||||||
|
long totalCount,
|
||||||
|
long failedCount,
|
||||||
|
double avgDurationMs,
|
||||||
|
double p99DurationMs,
|
||||||
|
double errorRate
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 = "Aggregated route performance metrics")
|
||||||
|
public record RouteMetrics(
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull String appId,
|
||||||
|
@NotNull long exchangeCount,
|
||||||
|
@NotNull double successRate,
|
||||||
|
@NotNull double avgDurationMs,
|
||||||
|
@NotNull double p99DurationMs,
|
||||||
|
@NotNull double errorRate,
|
||||||
|
@NotNull double throughputPerSec,
|
||||||
|
@NotNull List<Double> sparkline
|
||||||
|
) {}
|
||||||
@@ -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.time.Instant;
|
||||||
|
|
||||||
|
@Schema(description = "Summary of a route within an application")
|
||||||
|
public record RouteSummary(
|
||||||
|
@NotNull String routeId,
|
||||||
|
@NotNull long exchangeCount,
|
||||||
|
Instant lastSeen
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record SetPasswordRequest(
|
||||||
|
@NotBlank String password
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package com.cameleer3.server.app.rbac;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.rbac.*;
|
||||||
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RbacServiceImpl implements RbacService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final GroupRepository groupRepository;
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
|
||||||
|
public RbacServiceImpl(JdbcTemplate jdbc, UserRepository userRepository,
|
||||||
|
GroupRepository groupRepository, RoleRepository roleRepository) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<UserDetail> listUsers() {
|
||||||
|
return userRepository.findAll().stream()
|
||||||
|
.map(this::buildUserDetail)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetail getUser(String userId) {
|
||||||
|
UserInfo user = userRepository.findById(userId).orElse(null);
|
||||||
|
if (user == null) return null;
|
||||||
|
return buildUserDetail(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserDetail buildUserDetail(UserInfo user) {
|
||||||
|
List<RoleSummary> directRoles = getDirectRolesForUser(user.userId());
|
||||||
|
List<GroupSummary> directGroups = getDirectGroupsForUser(user.userId());
|
||||||
|
List<RoleSummary> effectiveRoles = getEffectiveRolesForUser(user.userId());
|
||||||
|
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(user.userId());
|
||||||
|
return new UserDetail(user.userId(), user.provider(), user.email(),
|
||||||
|
user.displayName(), user.createdAt(),
|
||||||
|
directRoles, directGroups, effectiveRoles, effectiveGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void assignRoleToUser(String userId, UUID roleId) {
|
||||||
|
jdbc.update("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||||
|
userId, roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeRoleFromUser(String userId, UUID roleId) {
|
||||||
|
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userId, roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addUserToGroup(String userId, UUID groupId) {
|
||||||
|
jdbc.update("INSERT INTO user_groups (user_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||||
|
userId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeUserFromGroup(String userId, UUID groupId) {
|
||||||
|
jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?", userId, groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RoleSummary> getEffectiveRolesForUser(String userId) {
|
||||||
|
List<RoleSummary> direct = getDirectRolesForUser(userId);
|
||||||
|
|
||||||
|
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(userId);
|
||||||
|
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
|
||||||
|
for (RoleSummary r : direct) {
|
||||||
|
roleMap.put(r.id(), r);
|
||||||
|
}
|
||||||
|
for (GroupSummary group : effectiveGroups) {
|
||||||
|
List<RoleSummary> groupRoles = jdbc.query("""
|
||||||
|
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||||
|
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||||
|
""", (rs, rowNum) -> new RoleSummary(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getBoolean("system"),
|
||||||
|
group.name()
|
||||||
|
), group.id());
|
||||||
|
for (RoleSummary r : groupRoles) {
|
||||||
|
roleMap.putIfAbsent(r.id(), r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(roleMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GroupSummary> getEffectiveGroupsForUser(String userId) {
|
||||||
|
List<GroupSummary> directGroups = getDirectGroupsForUser(userId);
|
||||||
|
Set<UUID> visited = new LinkedHashSet<>();
|
||||||
|
List<GroupSummary> all = new ArrayList<>();
|
||||||
|
for (GroupSummary g : directGroups) {
|
||||||
|
collectAncestors(g.id(), visited, all);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectAncestors(UUID groupId, Set<UUID> visited, List<GroupSummary> result) {
|
||||||
|
if (!visited.add(groupId)) return;
|
||||||
|
var rows = jdbc.query("SELECT id, name, parent_group_id FROM groups WHERE id = ?",
|
||||||
|
(rs, rowNum) -> new Object[]{
|
||||||
|
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
|
||||||
|
rs.getObject("parent_group_id", UUID.class)
|
||||||
|
}, groupId);
|
||||||
|
if (rows.isEmpty()) return;
|
||||||
|
result.add((GroupSummary) rows.get(0)[0]);
|
||||||
|
UUID parentId = (UUID) rows.get(0)[1];
|
||||||
|
if (parentId != null) {
|
||||||
|
collectAncestors(parentId, visited, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RoleSummary> getEffectiveRolesForGroup(UUID groupId) {
|
||||||
|
List<RoleSummary> direct = jdbc.query("""
|
||||||
|
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||||
|
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||||
|
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"), rs.getBoolean("system"), "direct"), groupId);
|
||||||
|
|
||||||
|
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
|
||||||
|
for (RoleSummary r : direct) roleMap.put(r.id(), r);
|
||||||
|
|
||||||
|
List<GroupSummary> ancestors = groupRepository.findAncestorChain(groupId);
|
||||||
|
for (GroupSummary ancestor : ancestors) {
|
||||||
|
if (ancestor.id().equals(groupId)) continue;
|
||||||
|
List<RoleSummary> parentRoles = jdbc.query("""
|
||||||
|
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||||
|
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||||
|
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"), rs.getBoolean("system"),
|
||||||
|
ancestor.name()), ancestor.id());
|
||||||
|
for (RoleSummary r : parentRoles) roleMap.putIfAbsent(r.id(), r);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(roleMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<UserSummary> getEffectivePrincipalsForRole(UUID roleId) {
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
List<UserSummary> result = new ArrayList<>();
|
||||||
|
|
||||||
|
List<UserSummary> direct = jdbc.query("""
|
||||||
|
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
|
||||||
|
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
|
||||||
|
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||||
|
rs.getString("display_name"), rs.getString("provider")), roleId);
|
||||||
|
for (UserSummary u : direct) {
|
||||||
|
if (seen.add(u.userId())) result.add(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UUID> groupsWithRole = jdbc.query(
|
||||||
|
"SELECT group_id FROM group_roles WHERE role_id = ?",
|
||||||
|
(rs, rowNum) -> rs.getObject("group_id", UUID.class), roleId);
|
||||||
|
|
||||||
|
Set<UUID> allGroups = new LinkedHashSet<>(groupsWithRole);
|
||||||
|
for (UUID gid : groupsWithRole) {
|
||||||
|
collectDescendants(gid, allGroups);
|
||||||
|
}
|
||||||
|
for (UUID gid : allGroups) {
|
||||||
|
List<UserSummary> members = jdbc.query("""
|
||||||
|
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
|
||||||
|
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
|
||||||
|
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||||
|
rs.getString("display_name"), rs.getString("provider")), gid);
|
||||||
|
for (UserSummary u : members) {
|
||||||
|
if (seen.add(u.userId())) result.add(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectDescendants(UUID groupId, Set<UUID> result) {
|
||||||
|
List<UUID> children = jdbc.query(
|
||||||
|
"SELECT id FROM groups WHERE parent_group_id = ?",
|
||||||
|
(rs, rowNum) -> rs.getObject("id", UUID.class), groupId);
|
||||||
|
for (UUID child : children) {
|
||||||
|
if (result.add(child)) {
|
||||||
|
collectDescendants(child, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getSystemRoleNames(String userId) {
|
||||||
|
return getEffectiveRolesForUser(userId).stream()
|
||||||
|
.filter(RoleSummary::system)
|
||||||
|
.map(RoleSummary::name)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RbacStats getStats() {
|
||||||
|
int userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
|
||||||
|
int activeUserCount = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(DISTINCT user_id) FROM user_roles", Integer.class);
|
||||||
|
int groupCount = jdbc.queryForObject("SELECT COUNT(*) FROM groups", Integer.class);
|
||||||
|
int roleCount = jdbc.queryForObject("SELECT COUNT(*) FROM roles", Integer.class);
|
||||||
|
int maxDepth = computeMaxGroupDepth();
|
||||||
|
return new RbacStats(userCount, activeUserCount, groupCount, maxDepth, roleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int computeMaxGroupDepth() {
|
||||||
|
List<UUID> roots = jdbc.query(
|
||||||
|
"SELECT id FROM groups WHERE parent_group_id IS NULL",
|
||||||
|
(rs, rowNum) -> rs.getObject("id", UUID.class));
|
||||||
|
int max = 0;
|
||||||
|
for (UUID root : roots) {
|
||||||
|
max = Math.max(max, measureDepth(root, 1));
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int measureDepth(UUID groupId, int currentDepth) {
|
||||||
|
List<UUID> children = jdbc.query(
|
||||||
|
"SELECT id FROM groups WHERE parent_group_id = ?",
|
||||||
|
(rs, rowNum) -> rs.getObject("id", UUID.class), groupId);
|
||||||
|
if (children.isEmpty()) return currentDepth;
|
||||||
|
int max = currentDepth;
|
||||||
|
for (UUID child : children) {
|
||||||
|
max = Math.max(max, measureDepth(child, currentDepth + 1));
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<RoleSummary> getDirectRolesForUser(String userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT r.id, r.name, r.system FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?
|
||||||
|
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"), rs.getBoolean("system"), "direct"), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<GroupSummary> getDirectGroupsForUser(String userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT g.id, g.name FROM user_groups ug
|
||||||
|
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = ?
|
||||||
|
""", (rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name")), userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,7 +288,7 @@ public class OpenSearchIndex implements SearchIndex {
|
|||||||
map.put("execution_id", doc.executionId());
|
map.put("execution_id", doc.executionId());
|
||||||
map.put("route_id", doc.routeId());
|
map.put("route_id", doc.routeId());
|
||||||
map.put("agent_id", doc.agentId());
|
map.put("agent_id", doc.agentId());
|
||||||
map.put("group_name", doc.groupName());
|
map.put("application_name", doc.applicationName());
|
||||||
map.put("status", doc.status());
|
map.put("status", doc.status());
|
||||||
map.put("correlation_id", doc.correlationId());
|
map.put("correlation_id", doc.correlationId());
|
||||||
map.put("exchange_id", doc.exchangeId());
|
map.put("exchange_id", doc.exchangeId());
|
||||||
@@ -323,6 +323,7 @@ public class OpenSearchIndex implements SearchIndex {
|
|||||||
(String) src.get("execution_id"),
|
(String) src.get("execution_id"),
|
||||||
(String) src.get("route_id"),
|
(String) src.get("route_id"),
|
||||||
(String) src.get("agent_id"),
|
(String) src.get("agent_id"),
|
||||||
|
(String) src.get("application_name"),
|
||||||
(String) src.get("status"),
|
(String) src.get("status"),
|
||||||
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
|
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
|
||||||
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,
|
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createAccessToken(String subject, String group, List<String> roles) {
|
public String createAccessToken(String subject, String application, List<String> roles) {
|
||||||
return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs());
|
return createToken(subject, application, roles, "access", properties.getAccessTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String createRefreshToken(String subject, String group, List<String> roles) {
|
public String createRefreshToken(String subject, String application, List<String> roles) {
|
||||||
return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
return createToken(subject, application, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -84,12 +84,12 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
return validateAccessToken(token).subject();
|
return validateAccessToken(token).subject();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String createToken(String subject, String group, List<String> roles,
|
private String createToken(String subject, String application, List<String> roles,
|
||||||
String type, long expiryMs) {
|
String type, long expiryMs) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.claim("group", group)
|
.claim("group", application)
|
||||||
.claim("type", type)
|
.claim("type", type)
|
||||||
.claim("roles", roles)
|
.claim("roles", roles)
|
||||||
.issueTime(Date.from(now))
|
.issueTime(Date.from(now))
|
||||||
@@ -132,7 +132,7 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
throw new InvalidTokenException("Token has no subject");
|
throw new InvalidTokenException("Token has no subject");
|
||||||
}
|
}
|
||||||
|
|
||||||
String group = claims.getStringClaim("group");
|
String application = claims.getStringClaim("group");
|
||||||
|
|
||||||
// Extract roles — may be absent in legacy tokens
|
// Extract roles — may be absent in legacy tokens
|
||||||
List<String> roles;
|
List<String> roles;
|
||||||
@@ -145,7 +145,7 @@ public class JwtServiceImpl implements JwtService {
|
|||||||
roles = List.of();
|
roles = List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JwtValidationResult(subject, group, roles);
|
return new JwtValidationResult(subject, application, roles);
|
||||||
} catch (ParseException e) {
|
} catch (ParseException e) {
|
||||||
throw new InvalidTokenException("Failed to parse JWT", e);
|
throw new InvalidTokenException("Failed to parse JWT", e);
|
||||||
} catch (JOSEException e) {
|
} catch (JOSEException e) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.cameleer3.server.app.dto.OidcPublicConfigResponse;
|
|||||||
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.rbac.RbacService;
|
||||||
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
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;
|
||||||
@@ -33,11 +35,12 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC authentication endpoints for the UI.
|
* OIDC authentication endpoints for the UI.
|
||||||
* <p>
|
* <p>
|
||||||
* Always registered — returns 404 when OIDC is not configured or disabled.
|
* Always registered -- returns 404 when OIDC is not configured or disabled.
|
||||||
* Configuration is read from the database (managed via admin UI).
|
* Configuration is read from the database (managed via admin UI).
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -52,17 +55,20 @@ public class OidcAuthController {
|
|||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final RbacService rbacService;
|
||||||
|
|
||||||
public OidcAuthController(OidcTokenExchanger tokenExchanger,
|
public OidcAuthController(OidcTokenExchanger tokenExchanger,
|
||||||
OidcConfigRepository configRepository,
|
OidcConfigRepository configRepository,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
AuditService auditService) {
|
AuditService auditService,
|
||||||
|
RbacService rbacService) {
|
||||||
this.tokenExchanger = tokenExchanger;
|
this.tokenExchanger = tokenExchanger;
|
||||||
this.configRepository = configRepository;
|
this.configRepository = configRepository;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.rbacService = rbacService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,11 +136,16 @@ public class OidcAuthController {
|
|||||||
"Account not provisioned. Contact your administrator.");
|
"Account not provisioned. Contact your administrator.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve roles: DB override > OIDC claim > default
|
// Upsert user (without roles -- roles are in user_roles table)
|
||||||
List<String> roles = resolveRoles(existingUser, oidcUser.roles(), config.get());
|
|
||||||
|
|
||||||
userRepository.upsert(new UserInfo(
|
userRepository.upsert(new UserInfo(
|
||||||
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
|
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
|
||||||
|
|
||||||
|
// Assign roles if new user
|
||||||
|
if (existingUser.isEmpty()) {
|
||||||
|
assignRolesForNewUser(userId, oidcUser.roles(), config.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> roles = rbacService.getSystemRoleNames(userId);
|
||||||
|
|
||||||
String accessToken = jwtService.createAccessToken(userId, "user", roles);
|
String accessToken = jwtService.createAccessToken(userId, "user", roles);
|
||||||
String refreshToken = jwtService.createRefreshToken(userId, "user", roles);
|
String refreshToken = jwtService.createRefreshToken(userId, "user", roles);
|
||||||
@@ -153,14 +164,14 @@ public class OidcAuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> resolveRoles(Optional<UserInfo> existing, List<String> oidcRoles, OidcConfig config) {
|
private void assignRolesForNewUser(String userId, List<String> oidcRoles, OidcConfig config) {
|
||||||
if (existing.isPresent() && !existing.get().roles().isEmpty()) {
|
List<String> roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles();
|
||||||
return existing.get().roles();
|
for (String roleName : roleNames) {
|
||||||
|
UUID roleId = SystemRole.BY_NAME.get(roleName.toUpperCase());
|
||||||
|
if (roleId != null) {
|
||||||
|
rbacService.assignRoleToUser(userId, roleId);
|
||||||
}
|
}
|
||||||
if (!oidcRoles.isEmpty()) {
|
|
||||||
return oidcRoles;
|
|
||||||
}
|
}
|
||||||
return config.defaultRoles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CallbackRequest(String code, String redirectUri) {}
|
public record CallbackRequest(String code, String redirectUri) {}
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ public class SecurityConfig {
|
|||||||
// Read-only data endpoints — viewer+
|
// Read-only data endpoints — viewer+
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.cameleer3.server.app.dto.ErrorResponse;
|
|||||||
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.rbac.RbacService;
|
||||||
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||||
@@ -19,6 +21,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -28,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication endpoints for the UI (local credentials).
|
* Authentication endpoints for the UI (local credentials).
|
||||||
@@ -42,18 +46,22 @@ import java.util.Map;
|
|||||||
public class UiAuthController {
|
public class UiAuthController {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
||||||
|
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final SecurityProperties properties;
|
private final SecurityProperties properties;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final RbacService rbacService;
|
||||||
|
|
||||||
public UiAuthController(JwtService jwtService, SecurityProperties properties,
|
public UiAuthController(JwtService jwtService, SecurityProperties properties,
|
||||||
UserRepository userRepository, AuditService auditService) {
|
UserRepository userRepository, AuditService auditService,
|
||||||
|
RbacService rbacService) {
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.rbacService = rbacService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -65,32 +73,41 @@ public class UiAuthController {
|
|||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
String configuredUser = properties.getUiUser();
|
String configuredUser = properties.getUiUser();
|
||||||
String configuredPassword = properties.getUiPassword();
|
String configuredPassword = properties.getUiPassword();
|
||||||
|
String subject = "user:" + request.username();
|
||||||
|
|
||||||
if (configuredUser == null || configuredUser.isBlank()
|
// Try env-var admin first
|
||||||
|| configuredPassword == null || configuredPassword.isBlank()) {
|
boolean envMatch = configuredUser != null && !configuredUser.isBlank()
|
||||||
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
|
&& configuredPassword != null && !configuredPassword.isBlank()
|
||||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
&& configuredUser.equals(request.username())
|
||||||
Map.of("reason", "UI authentication not configured"), AuditResult.FAILURE, httpRequest);
|
&& configuredPassword.equals(request.password());
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!configuredUser.equals(request.username())
|
if (!envMatch) {
|
||||||
|| !configuredPassword.equals(request.password())) {
|
// Try per-user password
|
||||||
|
Optional<String> hash = userRepository.getPasswordHash(subject);
|
||||||
|
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
|
||||||
log.debug("UI login failed for user: {}", request.username());
|
log.debug("UI login failed for user: {}", request.username());
|
||||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||||
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String subject = "user:" + request.username();
|
if (envMatch) {
|
||||||
List<String> roles = List.of("ADMIN");
|
// Env-var admin: upsert and ensure ADMIN role + Admins group
|
||||||
|
|
||||||
// Upsert local user into store
|
|
||||||
try {
|
try {
|
||||||
userRepository.upsert(new UserInfo(
|
userRepository.upsert(new UserInfo(
|
||||||
subject, "local", "", request.username(), roles, Instant.now()));
|
subject, "local", "", request.username(), Instant.now()));
|
||||||
|
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
|
||||||
|
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage());
|
log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Per-user logins: user already exists in DB (created by admin)
|
||||||
|
|
||||||
|
List<String> roles = rbacService.getSystemRoleNames(subject);
|
||||||
|
if (roles.isEmpty()) {
|
||||||
|
roles = List.of("VIEWER");
|
||||||
}
|
}
|
||||||
|
|
||||||
String accessToken = jwtService.createAccessToken(subject, "user", roles);
|
String accessToken = jwtService.createAccessToken(subject, "user", roles);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRecord;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresAgentEventRepository implements AgentEventRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PostgresAgentEventRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insert(String agentId, String appId, String eventType, String detail) {
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO agent_events (agent_id, app_id, event_type, detail) VALUES (?, ?, ?, ?)",
|
||||||
|
agentId, appId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||||
|
var sql = new StringBuilder("SELECT id, agent_id, app_id, event_type, detail, timestamp FROM agent_events WHERE 1=1");
|
||||||
|
var params = new ArrayList<Object>();
|
||||||
|
|
||||||
|
if (appId != null) {
|
||||||
|
sql.append(" AND app_id = ?");
|
||||||
|
params.add(appId);
|
||||||
|
}
|
||||||
|
if (agentId != null) {
|
||||||
|
sql.append(" AND agent_id = ?");
|
||||||
|
params.add(agentId);
|
||||||
|
}
|
||||||
|
if (from != null) {
|
||||||
|
sql.append(" AND timestamp >= ?");
|
||||||
|
params.add(Timestamp.from(from));
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
sql.append(" AND timestamp < ?");
|
||||||
|
params.add(Timestamp.from(to));
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY timestamp DESC LIMIT ?");
|
||||||
|
params.add(limit);
|
||||||
|
|
||||||
|
return jdbc.query(sql.toString(), (rs, rowNum) -> new AgentEventRecord(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("agent_id"),
|
||||||
|
rs.getString("app_id"),
|
||||||
|
rs.getString("event_type"),
|
||||||
|
rs.getString("detail"),
|
||||||
|
rs.getTimestamp("timestamp").toInstant()
|
||||||
|
), params.toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
@Override
|
@Override
|
||||||
public void upsert(ExecutionRecord execution) {
|
public void upsert(ExecutionRecord execution) {
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO executions (execution_id, route_id, agent_id, group_name,
|
INSERT INTO executions (execution_id, route_id, agent_id, application_name,
|
||||||
status, correlation_id, exchange_id, start_time, end_time,
|
status, correlation_id, exchange_id, start_time, end_time,
|
||||||
duration_ms, error_message, error_stacktrace, diagram_content_hash,
|
duration_ms, error_message, error_stacktrace, diagram_content_hash,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
@@ -45,7 +45,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
execution.executionId(), execution.routeId(), execution.agentId(),
|
execution.executionId(), execution.routeId(), execution.agentId(),
|
||||||
execution.groupName(), execution.status(), execution.correlationId(),
|
execution.applicationName(), execution.status(), execution.correlationId(),
|
||||||
execution.exchangeId(),
|
execution.exchangeId(),
|
||||||
Timestamp.from(execution.startTime()),
|
Timestamp.from(execution.startTime()),
|
||||||
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
|
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
|
||||||
@@ -55,11 +55,11 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void upsertProcessors(String executionId, Instant startTime,
|
public void upsertProcessors(String executionId, Instant startTime,
|
||||||
String groupName, String routeId,
|
String applicationName, String routeId,
|
||||||
List<ProcessorRecord> processors) {
|
List<ProcessorRecord> processors) {
|
||||||
jdbc.batchUpdate("""
|
jdbc.batchUpdate("""
|
||||||
INSERT INTO processor_executions (execution_id, processor_id, processor_type,
|
INSERT INTO processor_executions (execution_id, processor_id, processor_type,
|
||||||
diagram_node_id, group_name, route_id, depth, parent_processor_id,
|
diagram_node_id, application_name, route_id, depth, parent_processor_id,
|
||||||
status, start_time, end_time, duration_ms, error_message, error_stacktrace,
|
status, start_time, end_time, duration_ms, error_message, error_stacktrace,
|
||||||
input_body, output_body, input_headers, output_headers)
|
input_body, output_body, input_headers, output_headers)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
|
||||||
@@ -76,7 +76,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
""",
|
""",
|
||||||
processors.stream().map(p -> new Object[]{
|
processors.stream().map(p -> new Object[]{
|
||||||
p.executionId(), p.processorId(), p.processorType(),
|
p.executionId(), p.processorId(), p.processorType(),
|
||||||
p.diagramNodeId(), p.groupName(), p.routeId(),
|
p.diagramNodeId(), p.applicationName(), p.routeId(),
|
||||||
p.depth(), p.parentProcessorId(), p.status(),
|
p.depth(), p.parentProcessorId(), p.status(),
|
||||||
Timestamp.from(p.startTime()),
|
Timestamp.from(p.startTime()),
|
||||||
p.endTime() != null ? Timestamp.from(p.endTime()) : null,
|
p.endTime() != null ? Timestamp.from(p.endTime()) : null,
|
||||||
@@ -103,7 +103,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
|
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
|
||||||
new ExecutionRecord(
|
new ExecutionRecord(
|
||||||
rs.getString("execution_id"), rs.getString("route_id"),
|
rs.getString("execution_id"), rs.getString("route_id"),
|
||||||
rs.getString("agent_id"), rs.getString("group_name"),
|
rs.getString("agent_id"), rs.getString("application_name"),
|
||||||
rs.getString("status"), rs.getString("correlation_id"),
|
rs.getString("status"), rs.getString("correlation_id"),
|
||||||
rs.getString("exchange_id"),
|
rs.getString("exchange_id"),
|
||||||
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
||||||
@@ -115,7 +115,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
|||||||
new ProcessorRecord(
|
new ProcessorRecord(
|
||||||
rs.getString("execution_id"), rs.getString("processor_id"),
|
rs.getString("execution_id"), rs.getString("processor_id"),
|
||||||
rs.getString("processor_type"), rs.getString("diagram_node_id"),
|
rs.getString("processor_type"), rs.getString("diagram_node_id"),
|
||||||
rs.getString("group_name"), rs.getString("route_id"),
|
rs.getString("application_name"), rs.getString("route_id"),
|
||||||
rs.getInt("depth"), rs.getString("parent_processor_id"),
|
rs.getInt("depth"), rs.getString("parent_processor_id"),
|
||||||
rs.getString("status"),
|
rs.getString("status"),
|
||||||
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.rbac.*;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresGroupRepository implements GroupRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PostgresGroupRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GroupSummary> findAll() {
|
||||||
|
return jdbc.query("SELECT id, name FROM groups ORDER BY name",
|
||||||
|
(rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<GroupDetail> findById(UUID id) {
|
||||||
|
var rows = jdbc.query(
|
||||||
|
"SELECT id, name, parent_group_id, created_at FROM groups WHERE id = ?",
|
||||||
|
(rs, rowNum) -> new GroupDetail(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getObject("parent_group_id", UUID.class),
|
||||||
|
rs.getTimestamp("created_at").toInstant(),
|
||||||
|
List.of(), List.of(), List.of(), List.of()
|
||||||
|
), id);
|
||||||
|
if (rows.isEmpty()) return Optional.empty();
|
||||||
|
var g = rows.get(0);
|
||||||
|
|
||||||
|
List<RoleSummary> directRoles = jdbc.query("""
|
||||||
|
SELECT r.id, r.name, r.system FROM group_roles gr
|
||||||
|
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
|
||||||
|
""", (rs, rowNum) -> new RoleSummary(rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"), rs.getBoolean("system"), "direct"), id);
|
||||||
|
|
||||||
|
List<UserSummary> members = jdbc.query("""
|
||||||
|
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
|
||||||
|
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
|
||||||
|
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||||
|
rs.getString("display_name"), rs.getString("provider")), id);
|
||||||
|
|
||||||
|
List<GroupSummary> children = findChildGroups(id);
|
||||||
|
|
||||||
|
return Optional.of(new GroupDetail(g.id(), g.name(), g.parentGroupId(),
|
||||||
|
g.createdAt(), directRoles, List.of(), members, children));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UUID create(String name, UUID parentGroupId) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("INSERT INTO groups (id, name, parent_group_id) VALUES (?, ?, ?)",
|
||||||
|
id, name, parentGroupId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(UUID id, String name, UUID parentGroupId) {
|
||||||
|
jdbc.update("UPDATE groups SET name = COALESCE(?, name), parent_group_id = ? WHERE id = ?",
|
||||||
|
name, parentGroupId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(UUID id) {
|
||||||
|
jdbc.update("DELETE FROM groups WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addRole(UUID groupId, UUID roleId) {
|
||||||
|
jdbc.update("INSERT INTO group_roles (group_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||||
|
groupId, roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeRole(UUID groupId, UUID roleId) {
|
||||||
|
jdbc.update("DELETE FROM group_roles WHERE group_id = ? AND role_id = ?", groupId, roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GroupSummary> findChildGroups(UUID parentId) {
|
||||||
|
return jdbc.query("SELECT id, name FROM groups WHERE parent_group_id = ? ORDER BY name",
|
||||||
|
(rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
|
||||||
|
parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GroupSummary> findAncestorChain(UUID groupId) {
|
||||||
|
List<GroupSummary> chain = new ArrayList<>();
|
||||||
|
UUID current = groupId;
|
||||||
|
Set<UUID> visited = new HashSet<>();
|
||||||
|
while (current != null && visited.add(current)) {
|
||||||
|
UUID id = current;
|
||||||
|
var rows = jdbc.query(
|
||||||
|
"SELECT id, name, parent_group_id FROM groups WHERE id = ?",
|
||||||
|
(rs, rowNum) -> new Object[]{
|
||||||
|
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
|
||||||
|
rs.getObject("parent_group_id", UUID.class)
|
||||||
|
}, id);
|
||||||
|
if (rows.isEmpty()) break;
|
||||||
|
chain.add((GroupSummary) rows.get(0)[0]);
|
||||||
|
current = (UUID) rows.get(0)[1];
|
||||||
|
}
|
||||||
|
Collections.reverse(chain);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.cameleer3.server.app.storage;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.rbac.*;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresRoleRepository implements RoleRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PostgresRoleRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RoleDetail> findAll() {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT id, name, description, scope, system, created_at FROM roles ORDER BY system DESC, name
|
||||||
|
""", (rs, rowNum) -> new RoleDetail(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("description"),
|
||||||
|
rs.getString("scope"),
|
||||||
|
rs.getBoolean("system"),
|
||||||
|
rs.getTimestamp("created_at").toInstant(),
|
||||||
|
List.of(), List.of(), List.of()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RoleDetail> findById(UUID id) {
|
||||||
|
var rows = jdbc.query("""
|
||||||
|
SELECT id, name, description, scope, system, created_at FROM roles WHERE id = ?
|
||||||
|
""", (rs, rowNum) -> new RoleDetail(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("description"),
|
||||||
|
rs.getString("scope"),
|
||||||
|
rs.getBoolean("system"),
|
||||||
|
rs.getTimestamp("created_at").toInstant(),
|
||||||
|
List.of(), List.of(), List.of()
|
||||||
|
), id);
|
||||||
|
if (rows.isEmpty()) return Optional.empty();
|
||||||
|
var r = rows.get(0);
|
||||||
|
|
||||||
|
List<GroupSummary> assignedGroups = jdbc.query("""
|
||||||
|
SELECT g.id, g.name FROM group_roles gr
|
||||||
|
JOIN groups g ON g.id = gr.group_id WHERE gr.role_id = ?
|
||||||
|
""", (rs, rowNum) -> new GroupSummary(rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name")), id);
|
||||||
|
|
||||||
|
List<UserSummary> directUsers = jdbc.query("""
|
||||||
|
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
|
||||||
|
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
|
||||||
|
""", (rs, rowNum) -> new UserSummary(rs.getString("user_id"),
|
||||||
|
rs.getString("display_name"), rs.getString("provider")), id);
|
||||||
|
|
||||||
|
return Optional.of(new RoleDetail(r.id(), r.name(), r.description(),
|
||||||
|
r.scope(), r.system(), r.createdAt(), assignedGroups, directUsers, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UUID create(String name, String description, String scope) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("INSERT INTO roles (id, name, description, scope, system) VALUES (?, ?, ?, ?, false)",
|
||||||
|
id, name, description, scope);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(UUID id, String name, String description, String scope) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE roles SET name = COALESCE(?, name), description = COALESCE(?, description),
|
||||||
|
scope = COALESCE(?, scope) WHERE id = ? AND system = false
|
||||||
|
""", name, description, scope, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(UUID id) {
|
||||||
|
jdbc.update("DELETE FROM roles WHERE id = ? AND system = false", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,9 +29,9 @@ public class PostgresStatsStore implements StatsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ExecutionStats statsForApp(Instant from, Instant to, String groupName) {
|
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
|
||||||
return queryStats("stats_1m_app", from, to, List.of(
|
return queryStats("stats_1m_app", from, to, List.of(
|
||||||
new Filter("group_name", groupName)));
|
new Filter("application_name", applicationName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -56,9 +56,9 @@ public class PostgresStatsStore implements StatsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName) {
|
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
|
||||||
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
|
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
|
||||||
new Filter("group_name", groupName)), true);
|
new Filter("application_name", applicationName)), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -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()
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import com.cameleer3.server.core.security.UserRepository;
|
|||||||
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.sql.Timestamp;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -22,35 +20,28 @@ public class PostgresUserRepository implements UserRepository {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<UserInfo> findById(String userId) {
|
public Optional<UserInfo> findById(String userId) {
|
||||||
var results = jdbc.query(
|
var results = jdbc.query(
|
||||||
"SELECT * FROM users WHERE user_id = ?",
|
"SELECT user_id, provider, email, display_name, created_at FROM users WHERE user_id = ?",
|
||||||
(rs, rowNum) -> mapUser(rs), userId);
|
(rs, rowNum) -> mapUser(rs), userId);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<UserInfo> findAll() {
|
public List<UserInfo> findAll() {
|
||||||
return jdbc.query("SELECT * FROM users ORDER BY user_id",
|
return jdbc.query("SELECT user_id, provider, email, display_name, created_at FROM users ORDER BY user_id",
|
||||||
(rs, rowNum) -> mapUser(rs));
|
(rs, rowNum) -> mapUser(rs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void upsert(UserInfo user) {
|
public void upsert(UserInfo user) {
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO users (user_id, provider, email, display_name, roles, created_at, updated_at)
|
INSERT INTO users (user_id, provider, email, display_name, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, now(), now())
|
VALUES (?, ?, ?, ?, now(), now())
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
provider = EXCLUDED.provider, email = EXCLUDED.email,
|
provider = EXCLUDED.provider, email = EXCLUDED.email,
|
||||||
display_name = EXCLUDED.display_name, roles = EXCLUDED.roles,
|
display_name = EXCLUDED.display_name,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
user.userId(), user.provider(), user.email(), user.displayName(),
|
user.userId(), user.provider(), user.email(), user.displayName());
|
||||||
user.roles().toArray(new String[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void updateRoles(String userId, List<String> roles) {
|
|
||||||
jdbc.update("UPDATE users SET roles = ?, updated_at = now() WHERE user_id = ?",
|
|
||||||
roles.toArray(new String[0]), userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -58,14 +49,27 @@ public class PostgresUserRepository implements UserRepository {
|
|||||||
jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
|
jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setPassword(String userId, String passwordHash) {
|
||||||
|
jdbc.update("UPDATE users SET password_hash = ? WHERE user_id = ?", passwordHash, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> getPasswordHash(String userId) {
|
||||||
|
List<String> results = jdbc.query(
|
||||||
|
"SELECT password_hash FROM users WHERE user_id = ?",
|
||||||
|
(rs, rowNum) -> rs.getString("password_hash"),
|
||||||
|
userId);
|
||||||
|
if (results.isEmpty() || results.get(0) == null) return Optional.empty();
|
||||||
|
return Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
|
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||||
Array rolesArray = rs.getArray("roles");
|
|
||||||
String[] roles = rolesArray != null ? (String[]) rolesArray.getArray() : new String[0];
|
|
||||||
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
||||||
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
|
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
|
||||||
return new UserInfo(
|
return new UserInfo(
|
||||||
rs.getString("user_id"), rs.getString("provider"),
|
rs.getString("user_id"), rs.getString("provider"),
|
||||||
rs.getString("email"), rs.getString("display_name"),
|
rs.getString("email"), rs.getString("display_name"),
|
||||||
List.of(roles), createdAt);
|
createdAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
CREATE TABLE audit_log (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
target TEXT,
|
|
||||||
detail JSONB,
|
|
||||||
result TEXT NOT NULL,
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
|
||||||
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
|
||||||
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
|
||||||
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
|
||||||
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
|
||||||
CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit;
|
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
-- V1__init.sql - Consolidated schema for Cameleer3
|
||||||
|
|
||||||
|
-- Extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit;
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- RBAC
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
scope TEXT NOT NULL DEFAULT 'custom',
|
||||||
|
system BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO roles (id, name, description, scope, system) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
|
||||||
|
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
|
||||||
|
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Built-in Admins group
|
||||||
|
INSERT INTO groups (id, name) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||||
|
|
||||||
|
CREATE TABLE group_roles (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Assign ADMIN role to Admins group
|
||||||
|
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||||
|
|
||||||
|
CREATE TABLE user_groups (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_roles (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
|
||||||
|
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
|
||||||
|
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
|
||||||
|
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Execution data (TimescaleDB hypertables)
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE executions (
|
||||||
|
execution_id TEXT NOT NULL,
|
||||||
|
route_id TEXT NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
application_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
correlation_id TEXT,
|
||||||
|
exchange_id TEXT,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_ms BIGINT,
|
||||||
|
error_message TEXT,
|
||||||
|
error_stacktrace TEXT,
|
||||||
|
diagram_content_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (execution_id, start_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
||||||
|
|
||||||
|
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
||||||
|
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
||||||
|
CREATE INDEX idx_executions_app_time ON executions (application_name, start_time DESC);
|
||||||
|
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
||||||
|
|
||||||
|
CREATE TABLE processor_executions (
|
||||||
|
id BIGSERIAL,
|
||||||
|
execution_id TEXT NOT NULL,
|
||||||
|
processor_id TEXT NOT NULL,
|
||||||
|
processor_type TEXT NOT NULL,
|
||||||
|
diagram_node_id TEXT,
|
||||||
|
application_name TEXT NOT NULL,
|
||||||
|
route_id TEXT NOT NULL,
|
||||||
|
depth INT NOT NULL,
|
||||||
|
parent_processor_id TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_ms BIGINT,
|
||||||
|
error_message TEXT,
|
||||||
|
error_stacktrace TEXT,
|
||||||
|
input_body TEXT,
|
||||||
|
output_body TEXT,
|
||||||
|
input_headers JSONB,
|
||||||
|
output_headers JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (execution_id, processor_id, start_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('processor_executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
||||||
|
|
||||||
|
CREATE INDEX idx_proc_exec_execution ON processor_executions (execution_id);
|
||||||
|
CREATE INDEX idx_proc_exec_type_time ON processor_executions (processor_type, start_time DESC);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Agent metrics
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE agent_metrics (
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
metric_name TEXT NOT NULL,
|
||||||
|
metric_value DOUBLE PRECISION NOT NULL,
|
||||||
|
tags JSONB,
|
||||||
|
collected_at TIMESTAMPTZ NOT NULL,
|
||||||
|
server_received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT create_hypertable('agent_metrics', 'collected_at', chunk_time_interval => INTERVAL '1 day');
|
||||||
|
|
||||||
|
CREATE INDEX idx_metrics_agent_name ON agent_metrics (agent_id, metric_name, collected_at DESC);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Route diagrams
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE route_diagrams (
|
||||||
|
content_hash TEXT PRIMARY KEY,
|
||||||
|
route_id TEXT NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
definition TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Agent events
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE agent_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Server configuration
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE server_config (
|
||||||
|
config_key TEXT PRIMARY KEY,
|
||||||
|
config_val JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Admin
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
detail JSONB,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
||||||
|
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
||||||
|
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
||||||
|
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
||||||
|
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- Continuous aggregates
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW stats_1m_all
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
|
FROM executions
|
||||||
|
WHERE status IS NOT NULL
|
||||||
|
GROUP BY bucket
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW stats_1m_app
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
application_name,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
|
FROM executions
|
||||||
|
WHERE status IS NOT NULL
|
||||||
|
GROUP BY bucket, application_name
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW stats_1m_route
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
application_name,
|
||||||
|
route_id,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
|
FROM executions
|
||||||
|
WHERE status IS NOT NULL
|
||||||
|
GROUP BY bucket, application_name, route_id
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW stats_1m_processor
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
application_name,
|
||||||
|
route_id,
|
||||||
|
processor_type,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||||
|
FROM processor_executions
|
||||||
|
GROUP BY bucket, application_name, route_id, processor_type
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW stats_1m_processor_detail
|
||||||
|
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 minute', start_time) AS bucket,
|
||||||
|
application_name,
|
||||||
|
route_id,
|
||||||
|
processor_id,
|
||||||
|
processor_type,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||||
|
SUM(duration_ms) AS duration_sum,
|
||||||
|
MAX(duration_ms) AS duration_max,
|
||||||
|
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
|
||||||
|
FROM processor_executions
|
||||||
|
GROUP BY bucket, application_name, route_id, processor_id, processor_type
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
CREATE TABLE executions (
|
|
||||||
execution_id TEXT NOT NULL,
|
|
||||||
route_id TEXT NOT NULL,
|
|
||||||
agent_id TEXT NOT NULL,
|
|
||||||
group_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
correlation_id TEXT,
|
|
||||||
exchange_id TEXT,
|
|
||||||
start_time TIMESTAMPTZ NOT NULL,
|
|
||||||
end_time TIMESTAMPTZ,
|
|
||||||
duration_ms BIGINT,
|
|
||||||
error_message TEXT,
|
|
||||||
error_stacktrace TEXT,
|
|
||||||
diagram_content_hash TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (execution_id, start_time)
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
|
||||||
|
|
||||||
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
|
||||||
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
|
||||||
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC);
|
|
||||||
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- V2__policies.sql - TimescaleDB policies (must run outside transaction)
|
||||||
|
-- flyway:executeInTransaction=false
|
||||||
|
|
||||||
|
-- Agent metrics retention & compression
|
||||||
|
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
||||||
|
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
||||||
|
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
||||||
|
|
||||||
|
-- Continuous aggregate refresh policies
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
|
|
||||||
|
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
|
||||||
|
start_offset => INTERVAL '1 hour',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => true);
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
CREATE TABLE processor_executions (
|
|
||||||
id BIGSERIAL,
|
|
||||||
execution_id TEXT NOT NULL,
|
|
||||||
processor_id TEXT NOT NULL,
|
|
||||||
processor_type TEXT NOT NULL,
|
|
||||||
diagram_node_id TEXT,
|
|
||||||
group_name TEXT NOT NULL,
|
|
||||||
route_id TEXT NOT NULL,
|
|
||||||
depth INT NOT NULL,
|
|
||||||
parent_processor_id TEXT,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
start_time TIMESTAMPTZ NOT NULL,
|
|
||||||
end_time TIMESTAMPTZ,
|
|
||||||
duration_ms BIGINT,
|
|
||||||
error_message TEXT,
|
|
||||||
error_stacktrace TEXT,
|
|
||||||
input_body TEXT,
|
|
||||||
output_body TEXT,
|
|
||||||
input_headers JSONB,
|
|
||||||
output_headers JSONB,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE (execution_id, processor_id, start_time)
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('processor_executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
|
|
||||||
|
|
||||||
CREATE INDEX idx_proc_exec_execution ON processor_executions (execution_id);
|
|
||||||
CREATE INDEX idx_proc_exec_type_time ON processor_executions (processor_type, start_time DESC);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
CREATE TABLE agent_metrics (
|
|
||||||
agent_id TEXT NOT NULL,
|
|
||||||
metric_name TEXT NOT NULL,
|
|
||||||
metric_value DOUBLE PRECISION NOT NULL,
|
|
||||||
tags JSONB,
|
|
||||||
collected_at TIMESTAMPTZ NOT NULL,
|
|
||||||
server_received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('agent_metrics', 'collected_at', chunk_time_interval => INTERVAL '1 day');
|
|
||||||
|
|
||||||
CREATE INDEX idx_metrics_agent_name ON agent_metrics (agent_id, metric_name, collected_at DESC);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE route_diagrams (
|
|
||||||
content_hash TEXT PRIMARY KEY,
|
|
||||||
route_id TEXT NOT NULL,
|
|
||||||
agent_id TEXT NOT NULL,
|
|
||||||
definition TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE users (
|
|
||||||
user_id TEXT PRIMARY KEY,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
email TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
roles TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
CREATE TABLE oidc_config (
|
|
||||||
config_id TEXT PRIMARY KEY DEFAULT 'default',
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
issuer_uri TEXT,
|
|
||||||
client_id TEXT,
|
|
||||||
client_secret TEXT,
|
|
||||||
roles_claim TEXT,
|
|
||||||
default_roles TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
auto_signup BOOLEAN DEFAULT false,
|
|
||||||
display_name_claim TEXT,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
-- Global stats
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_all
|
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
|
||||||
SELECT
|
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
|
||||||
COUNT(*) AS total_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
|
||||||
SUM(duration_ms) AS duration_sum,
|
|
||||||
MAX(duration_ms) AS duration_max,
|
|
||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
|
||||||
FROM executions
|
|
||||||
WHERE status IS NOT NULL
|
|
||||||
GROUP BY bucket
|
|
||||||
WITH NO DATA;
|
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
-- Per-application stats
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_app
|
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
|
||||||
SELECT
|
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
|
||||||
group_name,
|
|
||||||
COUNT(*) AS total_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
|
||||||
SUM(duration_ms) AS duration_sum,
|
|
||||||
MAX(duration_ms) AS duration_max,
|
|
||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
|
||||||
FROM executions
|
|
||||||
WHERE status IS NOT NULL
|
|
||||||
GROUP BY bucket, group_name
|
|
||||||
WITH NO DATA;
|
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
-- Per-route stats
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_route
|
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
|
||||||
SELECT
|
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
|
||||||
group_name,
|
|
||||||
route_id,
|
|
||||||
COUNT(*) AS total_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
|
||||||
SUM(duration_ms) AS duration_sum,
|
|
||||||
MAX(duration_ms) AS duration_max,
|
|
||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
|
||||||
FROM executions
|
|
||||||
WHERE status IS NOT NULL
|
|
||||||
GROUP BY bucket, group_name, route_id
|
|
||||||
WITH NO DATA;
|
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
|
|
||||||
-- Per-processor stats (uses denormalized group_name/route_id on processor_executions)
|
|
||||||
CREATE MATERIALIZED VIEW stats_1m_processor
|
|
||||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
|
||||||
SELECT
|
|
||||||
time_bucket('1 minute', start_time) AS bucket,
|
|
||||||
group_name,
|
|
||||||
route_id,
|
|
||||||
processor_type,
|
|
||||||
COUNT(*) AS total_count,
|
|
||||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
|
||||||
SUM(duration_ms) AS duration_sum,
|
|
||||||
MAX(duration_ms) AS duration_max,
|
|
||||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
|
||||||
FROM processor_executions
|
|
||||||
GROUP BY bucket, group_name, route_id, processor_type
|
|
||||||
WITH NO DATA;
|
|
||||||
|
|
||||||
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
|
||||||
start_offset => INTERVAL '1 hour',
|
|
||||||
end_offset => INTERVAL '1 minute',
|
|
||||||
schedule_interval => INTERVAL '1 minute');
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
CREATE TABLE admin_thresholds (
|
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
||||||
config JSONB NOT NULL DEFAULT '{}',
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_by TEXT NOT NULL,
|
|
||||||
CONSTRAINT single_row CHECK (id = 1)
|
|
||||||
);
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ public class TestSecurityHelper {
|
|||||||
/**
|
/**
|
||||||
* Returns a valid JWT access token with the given roles (no agent registration).
|
* Returns a valid JWT access token with the given roles (no agent registration).
|
||||||
*/
|
*/
|
||||||
public String createToken(String subject, String group, List<String> roles) {
|
public String createToken(String subject, String application, List<String> roles) {
|
||||||
return jwtService.createAccessToken(subject, group, roles);
|
return jwtService.createAccessToken(subject, application, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,17 +38,17 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
|
|||||||
operatorJwt = securityHelper.operatorToken();
|
operatorJwt = securityHelper.operatorToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
|
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
|
||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "%s",
|
"name": "%s",
|
||||||
"group": "%s",
|
"application": "%s",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1"],
|
"routeIds": ["route-1"],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
}
|
}
|
||||||
""".formatted(agentId, name, group);
|
""".formatted(agentId, name, application);
|
||||||
|
|
||||||
return restTemplate.postForEntity(
|
return restTemplate.postForEntity(
|
||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "%s",
|
"name": "%s",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1", "route-2"],
|
"routeIds": ["route-1", "route-2"],
|
||||||
"capabilities": {"tracing": true}
|
"capabilities": {"tracing": true}
|
||||||
|
|||||||
@@ -53,17 +53,17 @@ class AgentSseControllerIT extends AbstractPostgresIT {
|
|||||||
operatorJwt = securityHelper.operatorToken();
|
operatorJwt = securityHelper.operatorToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
|
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
|
||||||
String json = """
|
String json = """
|
||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "%s",
|
"name": "%s",
|
||||||
"group": "%s",
|
"application": "%s",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1"],
|
"routeIds": ["route-1"],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
}
|
}
|
||||||
""".formatted(agentId, name, group);
|
""".formatted(agentId, name, application);
|
||||||
|
|
||||||
return restTemplate.postForEntity(
|
return restTemplate.postForEntity(
|
||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "bootstrap-test-agent",
|
"agentId": "bootstrap-test-agent",
|
||||||
"name": "Bootstrap Test",
|
"name": "Bootstrap Test",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
@@ -97,7 +97,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "bootstrap-test-previous",
|
"agentId": "bootstrap-test-previous",
|
||||||
"name": "Previous Token Test",
|
"name": "Previous Token Test",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "Refresh Test Agent",
|
"name": "Refresh Test Agent",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
@@ -79,6 +79,8 @@ class JwtRefreshIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
JsonNode body = objectMapper.readTree(response.getBody());
|
JsonNode body = objectMapper.readTree(response.getBody());
|
||||||
assertThat(body.get("accessToken").asText()).isNotEmpty();
|
assertThat(body.get("accessToken").asText()).isNotEmpty();
|
||||||
|
assertThat(body.get("refreshToken").asText()).isNotEmpty();
|
||||||
|
assertThat(body.get("refreshToken").asText()).isNotEqualTo(refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class JwtServiceTest {
|
|||||||
String token = jwtService.createAccessToken("user:admin", "user", roles);
|
String token = jwtService.createAccessToken("user:admin", "user", roles);
|
||||||
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
assertEquals("user:admin", result.subject());
|
assertEquals("user:admin", result.subject());
|
||||||
assertEquals("user", result.group());
|
assertEquals("user", result.application());
|
||||||
assertEquals(roles, result.roles());
|
assertEquals(roles, result.roles());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class JwtServiceTest {
|
|||||||
String token = jwtService.createRefreshToken("agent-1", "default", roles);
|
String token = jwtService.createRefreshToken("agent-1", "default", roles);
|
||||||
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
||||||
assertEquals("agent-1", result.subject());
|
assertEquals("agent-1", result.subject());
|
||||||
assertEquals("default", result.group());
|
assertEquals("default", result.application());
|
||||||
assertEquals(roles, result.roles());
|
assertEquals(roles, result.roles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "Security Test Agent",
|
"name": "Security Test Agent",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": [],
|
"routeIds": [],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class SseSigningIT extends AbstractPostgresIT {
|
|||||||
{
|
{
|
||||||
"agentId": "%s",
|
"agentId": "%s",
|
||||||
"name": "SSE Signing Test Agent",
|
"name": "SSE Signing Test Agent",
|
||||||
"group": "test-group",
|
"application": "test-group",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"routeIds": ["route-1"],
|
"routeIds": ["route-1"],
|
||||||
"capabilities": {}
|
"capabilities": {}
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
|
|||||||
assertFalse(ts.buckets().isEmpty());
|
assertFalse(ts.buckets().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void insertExecution(String id, String routeId, String groupName,
|
private void insertExecution(String id, String routeId, String applicationName,
|
||||||
String status, Instant startTime, long durationMs) {
|
String status, Instant startTime, long durationMs) {
|
||||||
executionStore.upsert(new ExecutionRecord(
|
executionStore.upsert(new ExecutionRecord(
|
||||||
id, routeId, "agent-1", groupName, status, null, null,
|
id, routeId, "agent-1", applicationName, status, null, null,
|
||||||
startTime, startTime.plusMillis(durationMs), durationMs,
|
startTime, startTime.plusMillis(durationMs), durationMs,
|
||||||
status.equals("FAILED") ? "error" : null, null, null));
|
status.equals("FAILED") ? "error" : null, null, null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.cameleer3.server.core.admin;
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
public enum AuditCategory {
|
public enum AuditCategory {
|
||||||
INFRA, AUTH, USER_MGMT, CONFIG
|
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record AgentEventRecord(
|
||||||
|
long id,
|
||||||
|
String agentId,
|
||||||
|
String appId,
|
||||||
|
String eventType,
|
||||||
|
String detail,
|
||||||
|
Instant timestamp
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AgentEventRepository {
|
||||||
|
|
||||||
|
void insert(String agentId, String appId, String eventType, String detail);
|
||||||
|
|
||||||
|
List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.cameleer3.server.core.agent;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class AgentEventService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentEventService.class);
|
||||||
|
|
||||||
|
private final AgentEventRepository repository;
|
||||||
|
|
||||||
|
public AgentEventService(AgentEventRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordEvent(String agentId, String appId, String eventType, String detail) {
|
||||||
|
log.debug("Recording agent event: agent={}, app={}, type={}", agentId, appId, eventType);
|
||||||
|
repository.insert(agentId, appId, eventType, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AgentEventRecord> queryEvents(String appId, String agentId, Instant from, Instant to, int limit) {
|
||||||
|
return repository.query(appId, agentId, from, to, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
|||||||
*
|
*
|
||||||
* @param id agent-provided persistent identifier
|
* @param id agent-provided persistent identifier
|
||||||
* @param name human-readable agent name
|
* @param name human-readable agent name
|
||||||
* @param group logical grouping (e.g., "order-service-prod")
|
* @param application application name (e.g., "order-service-prod")
|
||||||
* @param version agent software version
|
* @param version agent software version
|
||||||
* @param routeIds list of Camel route IDs managed by this agent
|
* @param routeIds list of Camel route IDs managed by this agent
|
||||||
* @param capabilities agent-declared capabilities (free-form)
|
* @param capabilities agent-declared capabilities (free-form)
|
||||||
@@ -25,7 +25,7 @@ import java.util.Map;
|
|||||||
public record AgentInfo(
|
public record AgentInfo(
|
||||||
String id,
|
String id,
|
||||||
String name,
|
String name,
|
||||||
String group,
|
String application,
|
||||||
String version,
|
String version,
|
||||||
List<String> routeIds,
|
List<String> routeIds,
|
||||||
Map<String, Object> capabilities,
|
Map<String, Object> capabilities,
|
||||||
@@ -36,28 +36,28 @@ public record AgentInfo(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
public AgentInfo withState(AgentState newState) {
|
public AgentInfo withState(AgentState newState) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
newState, registeredAt, lastHeartbeat, staleTransitionTime);
|
newState, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
|
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, registeredAt, newLastHeartbeat, staleTransitionTime);
|
state, registeredAt, newLastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
|
public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
|
state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
|
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, registeredAt, lastHeartbeat, newStaleTransitionTime);
|
state, registeredAt, lastHeartbeat, newStaleTransitionTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentInfo withMetadata(String name, String group, String version,
|
public AgentInfo withMetadata(String name, String application, String version,
|
||||||
List<String> routeIds, Map<String, Object> capabilities) {
|
List<String> routeIds, Map<String, Object> capabilities) {
|
||||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||||
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ public class AgentRegistryService {
|
|||||||
* Register a new agent or re-register an existing one.
|
* Register a new agent or re-register an existing one.
|
||||||
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
|
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
|
||||||
*/
|
*/
|
||||||
public AgentInfo register(String id, String name, String group, String version,
|
public AgentInfo register(String id, String name, String application, String version,
|
||||||
List<String> routeIds, Map<String, Object> capabilities) {
|
List<String> routeIds, Map<String, Object> capabilities) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
AgentInfo newAgent = new AgentInfo(id, name, group, version,
|
AgentInfo newAgent = new AgentInfo(id, name, application, version,
|
||||||
List.copyOf(routeIds), Map.copyOf(capabilities),
|
List.copyOf(routeIds), Map.copyOf(capabilities),
|
||||||
AgentState.LIVE, now, now, null);
|
AgentState.LIVE, now, now, null);
|
||||||
|
|
||||||
@@ -55,13 +55,13 @@ public class AgentRegistryService {
|
|||||||
// Re-registration: update metadata, reset to LIVE
|
// Re-registration: update metadata, reset to LIVE
|
||||||
log.info("Agent {} re-registering (was {})", id, existing.state());
|
log.info("Agent {} re-registering (was {})", id, existing.state());
|
||||||
return existing
|
return existing
|
||||||
.withMetadata(name, group, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
.withMetadata(name, application, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
||||||
.withState(AgentState.LIVE)
|
.withState(AgentState.LIVE)
|
||||||
.withLastHeartbeat(now)
|
.withLastHeartbeat(now)
|
||||||
.withRegisteredAt(now)
|
.withRegisteredAt(now)
|
||||||
.withStaleTransitionTime(null);
|
.withStaleTransitionTime(null);
|
||||||
}
|
}
|
||||||
log.info("Agent {} registered (name={}, group={})", id, name, group);
|
log.info("Agent {} registered (name={}, application={})", id, name, application);
|
||||||
return newAgent;
|
return newAgent;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,11 +168,11 @@ public class AgentRegistryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all agents belonging to the given application group.
|
* Return all agents belonging to the given application.
|
||||||
*/
|
*/
|
||||||
public List<AgentInfo> findByGroup(String group) {
|
public List<AgentInfo> findByApplication(String application) {
|
||||||
return agents.values().stream()
|
return agents.values().stream()
|
||||||
.filter(a -> group.equals(a.group()))
|
.filter(a -> application.equals(a.application()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class DetailService {
|
|||||||
List<ProcessorNode> roots = buildTree(processors);
|
List<ProcessorNode> roots = buildTree(processors);
|
||||||
return new ExecutionDetail(
|
return new ExecutionDetail(
|
||||||
exec.executionId(), exec.routeId(), exec.agentId(),
|
exec.executionId(), exec.routeId(), exec.agentId(),
|
||||||
|
exec.applicationName(),
|
||||||
exec.status(), exec.startTime(), exec.endTime(),
|
exec.status(), exec.startTime(), exec.endTime(),
|
||||||
exec.durationMs() != null ? exec.durationMs() : 0L,
|
exec.durationMs() != null ? exec.durationMs() : 0L,
|
||||||
exec.correlationId(), exec.exchangeId(),
|
exec.correlationId(), exec.exchangeId(),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public record ExecutionDetail(
|
|||||||
String executionId,
|
String executionId,
|
||||||
String routeId,
|
String routeId,
|
||||||
String agentId,
|
String agentId,
|
||||||
|
String applicationName,
|
||||||
String status,
|
String status,
|
||||||
Instant startTime,
|
Instant startTime,
|
||||||
Instant endTime,
|
Instant endTime,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class SearchIndexer implements SearchIndexerStats {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
searchIndex.index(new ExecutionDocument(
|
searchIndex.index(new ExecutionDocument(
|
||||||
exec.executionId(), exec.routeId(), exec.agentId(), exec.groupName(),
|
exec.executionId(), exec.routeId(), exec.agentId(), exec.applicationName(),
|
||||||
exec.status(), exec.correlationId(), exec.exchangeId(),
|
exec.status(), exec.correlationId(), exec.exchangeId(),
|
||||||
exec.startTime(), exec.endTime(), exec.durationMs(),
|
exec.startTime(), exec.endTime(), exec.durationMs(),
|
||||||
exec.errorMessage(), exec.errorStacktrace(), processorDocs));
|
exec.errorMessage(), exec.errorStacktrace(), processorDocs));
|
||||||
|
|||||||
@@ -38,18 +38,18 @@ public class IngestionService {
|
|||||||
this.bodySizeLimit = bodySizeLimit;
|
this.bodySizeLimit = bodySizeLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ingestExecution(String agentId, String groupName, RouteExecution execution) {
|
public void ingestExecution(String agentId, String applicationName, RouteExecution execution) {
|
||||||
ExecutionRecord record = toExecutionRecord(agentId, groupName, execution);
|
ExecutionRecord record = toExecutionRecord(agentId, applicationName, execution);
|
||||||
executionStore.upsert(record);
|
executionStore.upsert(record);
|
||||||
|
|
||||||
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
|
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
|
||||||
List<ProcessorRecord> processors = flattenProcessors(
|
List<ProcessorRecord> processors = flattenProcessors(
|
||||||
execution.getProcessors(), record.executionId(),
|
execution.getProcessors(), record.executionId(),
|
||||||
record.startTime(), groupName, execution.getRouteId(),
|
record.startTime(), applicationName, execution.getRouteId(),
|
||||||
null, 0);
|
null, 0);
|
||||||
executionStore.upsertProcessors(
|
executionStore.upsertProcessors(
|
||||||
record.executionId(), record.startTime(),
|
record.executionId(), record.startTime(),
|
||||||
groupName, execution.getRouteId(), processors);
|
applicationName, execution.getRouteId(), processors);
|
||||||
}
|
}
|
||||||
|
|
||||||
eventPublisher.accept(new ExecutionUpdatedEvent(
|
eventPublisher.accept(new ExecutionUpdatedEvent(
|
||||||
@@ -72,13 +72,13 @@ public class IngestionService {
|
|||||||
return metricsBuffer;
|
return metricsBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExecutionRecord toExecutionRecord(String agentId, String groupName,
|
private ExecutionRecord toExecutionRecord(String agentId, String applicationName,
|
||||||
RouteExecution exec) {
|
RouteExecution exec) {
|
||||||
String diagramHash = diagramStore
|
String diagramHash = diagramStore
|
||||||
.findContentHashForRoute(exec.getRouteId(), agentId)
|
.findContentHashForRoute(exec.getRouteId(), agentId)
|
||||||
.orElse("");
|
.orElse("");
|
||||||
return new ExecutionRecord(
|
return new ExecutionRecord(
|
||||||
exec.getExchangeId(), exec.getRouteId(), agentId, groupName,
|
exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
|
||||||
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
|
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
|
||||||
exec.getCorrelationId(), exec.getExchangeId(),
|
exec.getCorrelationId(), exec.getExchangeId(),
|
||||||
exec.getStartTime(), exec.getEndTime(),
|
exec.getStartTime(), exec.getEndTime(),
|
||||||
@@ -90,13 +90,13 @@ public class IngestionService {
|
|||||||
|
|
||||||
private List<ProcessorRecord> flattenProcessors(
|
private List<ProcessorRecord> flattenProcessors(
|
||||||
List<ProcessorExecution> processors, String executionId,
|
List<ProcessorExecution> processors, String executionId,
|
||||||
java.time.Instant execStartTime, String groupName, String routeId,
|
java.time.Instant execStartTime, String applicationName, String routeId,
|
||||||
String parentProcessorId, int depth) {
|
String parentProcessorId, int depth) {
|
||||||
List<ProcessorRecord> flat = new ArrayList<>();
|
List<ProcessorRecord> flat = new ArrayList<>();
|
||||||
for (ProcessorExecution p : processors) {
|
for (ProcessorExecution p : processors) {
|
||||||
flat.add(new ProcessorRecord(
|
flat.add(new ProcessorRecord(
|
||||||
executionId, p.getProcessorId(), p.getProcessorType(),
|
executionId, p.getProcessorId(), p.getProcessorType(),
|
||||||
p.getDiagramNodeId(), groupName, routeId,
|
p.getDiagramNodeId(), applicationName, routeId,
|
||||||
depth, parentProcessorId,
|
depth, parentProcessorId,
|
||||||
p.getStatus() != null ? p.getStatus().name() : "RUNNING",
|
p.getStatus() != null ? p.getStatus().name() : "RUNNING",
|
||||||
p.getStartTime() != null ? p.getStartTime() : execStartTime,
|
p.getStartTime() != null ? p.getStartTime() : execStartTime,
|
||||||
@@ -109,7 +109,7 @@ public class IngestionService {
|
|||||||
if (p.getChildren() != null) {
|
if (p.getChildren() != null) {
|
||||||
flat.addAll(flattenProcessors(
|
flat.addAll(flattenProcessors(
|
||||||
p.getChildren(), executionId, execStartTime,
|
p.getChildren(), executionId, execStartTime,
|
||||||
groupName, routeId, p.getProcessorId(), depth + 1));
|
applicationName, routeId, p.getProcessorId(), depth + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return flat;
|
return flat;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GroupDetail(UUID id, String name, UUID parentGroupId, Instant createdAt,
|
||||||
|
List<RoleSummary> directRoles, List<RoleSummary> effectiveRoles,
|
||||||
|
List<UserSummary> members, List<GroupSummary> childGroups) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface GroupRepository {
|
||||||
|
List<GroupSummary> findAll();
|
||||||
|
Optional<GroupDetail> findById(UUID id);
|
||||||
|
UUID create(String name, UUID parentGroupId);
|
||||||
|
void update(UUID id, String name, UUID parentGroupId);
|
||||||
|
void delete(UUID id);
|
||||||
|
void addRole(UUID groupId, UUID roleId);
|
||||||
|
void removeRole(UUID groupId, UUID roleId);
|
||||||
|
List<GroupSummary> findChildGroups(UUID parentId);
|
||||||
|
List<GroupSummary> findAncestorChain(UUID groupId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GroupSummary(UUID id, String name) {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface RbacService {
|
||||||
|
List<UserDetail> listUsers();
|
||||||
|
UserDetail getUser(String userId);
|
||||||
|
void assignRoleToUser(String userId, UUID roleId);
|
||||||
|
void removeRoleFromUser(String userId, UUID roleId);
|
||||||
|
void addUserToGroup(String userId, UUID groupId);
|
||||||
|
void removeUserFromGroup(String userId, UUID groupId);
|
||||||
|
List<RoleSummary> getEffectiveRolesForUser(String userId);
|
||||||
|
List<GroupSummary> getEffectiveGroupsForUser(String userId);
|
||||||
|
List<RoleSummary> getEffectiveRolesForGroup(UUID groupId);
|
||||||
|
List<UserSummary> getEffectivePrincipalsForRole(UUID roleId);
|
||||||
|
List<String> getSystemRoleNames(String userId);
|
||||||
|
RbacStats getStats();
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record RoleDetail(UUID id, String name, String description, String scope, boolean system,
|
||||||
|
Instant createdAt, List<GroupSummary> assignedGroups, List<UserSummary> directUsers,
|
||||||
|
List<UserSummary> effectivePrincipals) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface RoleRepository {
|
||||||
|
List<RoleDetail> findAll();
|
||||||
|
Optional<RoleDetail> findById(UUID id);
|
||||||
|
UUID create(String name, String description, String scope);
|
||||||
|
void update(UUID id, String name, String description, String scope);
|
||||||
|
void delete(UUID id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record RoleSummary(UUID id, String name, boolean system, String source) {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class SystemRole {
|
||||||
|
private SystemRole() {}
|
||||||
|
|
||||||
|
public static final UUID AGENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||||
|
public static final UUID VIEWER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||||
|
public static final UUID OPERATOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||||
|
public static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000004");
|
||||||
|
|
||||||
|
public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||||
|
|
||||||
|
public static final Set<UUID> IDS = Set.of(AGENT_ID, VIEWER_ID, OPERATOR_ID, ADMIN_ID);
|
||||||
|
|
||||||
|
public static final Map<String, UUID> BY_NAME = Map.of(
|
||||||
|
"AGENT", AGENT_ID, "VIEWER", VIEWER_ID, "OPERATOR", OPERATOR_ID, "ADMIN", ADMIN_ID);
|
||||||
|
|
||||||
|
public static boolean isSystem(UUID id) { return IDS.contains(id); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record UserDetail(String userId, String provider, String email, String displayName,
|
||||||
|
Instant createdAt, List<RoleSummary> directRoles, List<GroupSummary> directGroups,
|
||||||
|
List<RoleSummary> effectiveRoles, List<GroupSummary> effectiveGroups) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
public record UserSummary(String userId, String displayName, String provider) {}
|
||||||
@@ -23,6 +23,7 @@ public record ExecutionSummary(
|
|||||||
String executionId,
|
String executionId,
|
||||||
String routeId,
|
String routeId,
|
||||||
String agentId,
|
String agentId,
|
||||||
|
String applicationName,
|
||||||
String status,
|
String status,
|
||||||
Instant startTime,
|
Instant startTime,
|
||||||
Instant endTime,
|
Instant endTime,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import java.util.List;
|
|||||||
* @param routeId exact match on route_id
|
* @param routeId exact match on route_id
|
||||||
* @param agentId exact match on agent_id
|
* @param agentId exact match on agent_id
|
||||||
* @param processorType matches processor_types array via has()
|
* @param processorType matches processor_types array via has()
|
||||||
* @param group application group filter (resolved to agentIds server-side)
|
* @param application application name filter (resolved to agentIds server-side)
|
||||||
* @param agentIds list of agent IDs (resolved from group, used for IN clause)
|
* @param agentIds list of agent IDs (resolved from group, used for IN clause)
|
||||||
* @param offset pagination offset (0-based)
|
* @param offset pagination offset (0-based)
|
||||||
* @param limit page size (default 50, max 500)
|
* @param limit page size (default 50, max 500)
|
||||||
@@ -43,7 +43,7 @@ public record SearchRequest(
|
|||||||
String routeId,
|
String routeId,
|
||||||
String agentId,
|
String agentId,
|
||||||
String processorType,
|
String processorType,
|
||||||
String group,
|
String application,
|
||||||
List<String> agentIds,
|
List<String> agentIds,
|
||||||
int offset,
|
int offset,
|
||||||
int limit,
|
int limit,
|
||||||
@@ -80,12 +80,12 @@ public record SearchRequest(
|
|||||||
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
|
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a copy with resolved agentIds (from group lookup). */
|
/** Create a copy with resolved agentIds (from application name lookup). */
|
||||||
public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
|
public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
|
||||||
return new SearchRequest(
|
return new SearchRequest(
|
||||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||||
text, textInBody, textInHeaders, textInErrors,
|
text, textInBody, textInHeaders, textInErrors,
|
||||||
routeId, agentId, processorType, group, resolvedAgentIds,
|
routeId, agentId, processorType, application, resolvedAgentIds,
|
||||||
offset, limit, sortField, sortDir
|
offset, limit, sortField, sortDir
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public class SearchService {
|
|||||||
return statsStore.stats(from, to);
|
return statsStore.stats(from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
|
||||||
|
return statsStore.statsForApp(from, to, applicationName);
|
||||||
|
}
|
||||||
|
|
||||||
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
|
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
|
||||||
return statsStore.statsForRoute(from, to, routeId, agentIds);
|
return statsStore.statsForRoute(from, to, routeId, agentIds);
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,10 @@ public class SearchService {
|
|||||||
return statsStore.timeseries(from, to, bucketCount);
|
return statsStore.timeseries(from, to, bucketCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
|
||||||
|
return statsStore.timeseriesForApp(from, to, bucketCount, applicationName);
|
||||||
|
}
|
||||||
|
|
||||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
||||||
String routeId, List<String> agentIds) {
|
String routeId, List<String> agentIds) {
|
||||||
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
|
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ public interface JwtService {
|
|||||||
* Validated JWT payload.
|
* Validated JWT payload.
|
||||||
*
|
*
|
||||||
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
|
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
|
||||||
* @param group the {@code group} claim
|
* @param application the {@code group} claim (application name)
|
||||||
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
||||||
*/
|
*/
|
||||||
record JwtValidationResult(String subject, String group, List<String> roles) {}
|
record JwtValidationResult(String subject, String application, List<String> roles) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed access JWT with the given subject, group, and roles.
|
* Creates a signed access JWT with the given subject, application, and roles.
|
||||||
*/
|
*/
|
||||||
String createAccessToken(String subject, String group, List<String> roles);
|
String createAccessToken(String subject, String application, List<String> roles);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a signed refresh JWT with the given subject, group, and roles.
|
* Creates a signed refresh JWT with the given subject, application, and roles.
|
||||||
*/
|
*/
|
||||||
String createRefreshToken(String subject, String group, List<String> roles);
|
String createRefreshToken(String subject, String application, List<String> roles);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an access token and returns the full validation result.
|
* Validates an access token and returns the full validation result.
|
||||||
@@ -46,12 +46,12 @@ public interface JwtService {
|
|||||||
|
|
||||||
// --- Backward-compatible defaults (delegate to role-aware methods) ---
|
// --- Backward-compatible defaults (delegate to role-aware methods) ---
|
||||||
|
|
||||||
default String createAccessToken(String subject, String group) {
|
default String createAccessToken(String subject, String application) {
|
||||||
return createAccessToken(subject, group, List.of());
|
return createAccessToken(subject, application, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
default String createRefreshToken(String subject, String group) {
|
default String createRefreshToken(String subject, String application) {
|
||||||
return createRefreshToken(subject, group, List.of());
|
return createRefreshToken(subject, application, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
default String validateAndExtractAgentId(String token) {
|
default String validateAndExtractAgentId(String token) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.cameleer3.server.core.security;
|
package com.cameleer3.server.core.security;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a persisted user in the system.
|
* Represents a persisted user in the system.
|
||||||
@@ -10,7 +9,6 @@ import java.util.List;
|
|||||||
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
|
* @param provider authentication provider ({@code "local"}, {@code "oidc:<issuer-host>"})
|
||||||
* @param email user email (may be empty)
|
* @param email user email (may be empty)
|
||||||
* @param displayName display name (may be empty)
|
* @param displayName display name (may be empty)
|
||||||
* @param roles assigned roles (e.g. {@code ["ADMIN"]}, {@code ["VIEWER"]})
|
|
||||||
* @param createdAt first creation timestamp
|
* @param createdAt first creation timestamp
|
||||||
*/
|
*/
|
||||||
public record UserInfo(
|
public record UserInfo(
|
||||||
@@ -18,6 +16,5 @@ public record UserInfo(
|
|||||||
String provider,
|
String provider,
|
||||||
String email,
|
String email,
|
||||||
String displayName,
|
String displayName,
|
||||||
List<String> roles,
|
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ public interface UserRepository {
|
|||||||
|
|
||||||
void upsert(UserInfo user);
|
void upsert(UserInfo user);
|
||||||
|
|
||||||
void updateRoles(String userId, List<String> roles);
|
|
||||||
|
|
||||||
void delete(String userId);
|
void delete(String userId);
|
||||||
|
|
||||||
|
void setPassword(String userId, String passwordHash);
|
||||||
|
|
||||||
|
Optional<String> getPasswordHash(String userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public interface ExecutionStore {
|
|||||||
void upsert(ExecutionRecord execution);
|
void upsert(ExecutionRecord execution);
|
||||||
|
|
||||||
void upsertProcessors(String executionId, Instant startTime,
|
void upsertProcessors(String executionId, Instant startTime,
|
||||||
String groupName, String routeId,
|
String applicationName, String routeId,
|
||||||
List<ProcessorRecord> processors);
|
List<ProcessorRecord> processors);
|
||||||
|
|
||||||
Optional<ExecutionRecord> findById(String executionId);
|
Optional<ExecutionRecord> findById(String executionId);
|
||||||
@@ -17,7 +17,7 @@ public interface ExecutionStore {
|
|||||||
List<ProcessorRecord> findProcessors(String executionId);
|
List<ProcessorRecord> findProcessors(String executionId);
|
||||||
|
|
||||||
record ExecutionRecord(
|
record ExecutionRecord(
|
||||||
String executionId, String routeId, String agentId, String groupName,
|
String executionId, String routeId, String agentId, String applicationName,
|
||||||
String status, String correlationId, String exchangeId,
|
String status, String correlationId, String exchangeId,
|
||||||
Instant startTime, Instant endTime, Long durationMs,
|
Instant startTime, Instant endTime, Long durationMs,
|
||||||
String errorMessage, String errorStacktrace, String diagramContentHash
|
String errorMessage, String errorStacktrace, String diagramContentHash
|
||||||
@@ -25,7 +25,7 @@ public interface ExecutionStore {
|
|||||||
|
|
||||||
record ProcessorRecord(
|
record ProcessorRecord(
|
||||||
String executionId, String processorId, String processorType,
|
String executionId, String processorId, String processorType,
|
||||||
String diagramNodeId, String groupName, String routeId,
|
String diagramNodeId, String applicationName, String routeId,
|
||||||
int depth, String parentProcessorId, String status,
|
int depth, String parentProcessorId, String status,
|
||||||
Instant startTime, Instant endTime, Long durationMs,
|
Instant startTime, Instant endTime, Long durationMs,
|
||||||
String errorMessage, String errorStacktrace,
|
String errorMessage, String errorStacktrace,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public interface StatsStore {
|
|||||||
ExecutionStats stats(Instant from, Instant to);
|
ExecutionStats stats(Instant from, Instant to);
|
||||||
|
|
||||||
// Per-app stats (stats_1m_app)
|
// Per-app stats (stats_1m_app)
|
||||||
ExecutionStats statsForApp(Instant from, Instant to, String groupName);
|
ExecutionStats statsForApp(Instant from, Instant to, String applicationName);
|
||||||
|
|
||||||
// Per-route stats (stats_1m_route), optionally scoped to specific agents
|
// Per-route stats (stats_1m_route), optionally scoped to specific agents
|
||||||
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
|
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
|
||||||
@@ -24,7 +24,7 @@ public interface StatsStore {
|
|||||||
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
|
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
|
||||||
|
|
||||||
// Per-app timeseries
|
// Per-app timeseries
|
||||||
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName);
|
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName);
|
||||||
|
|
||||||
// Per-route timeseries, optionally scoped to specific agents
|
// Per-route timeseries, optionally scoped to specific agents
|
||||||
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record ExecutionDocument(
|
public record ExecutionDocument(
|
||||||
String executionId, String routeId, String agentId, String groupName,
|
String executionId, String routeId, String agentId, String applicationName,
|
||||||
String status, String correlationId, String exchangeId,
|
String status, String correlationId, String exchangeId,
|
||||||
Instant startTime, Instant endTime, Long durationMs,
|
Instant startTime, Instant endTime, Long durationMs,
|
||||||
String errorMessage, String errorStacktrace,
|
String errorMessage, String errorStacktrace,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user