Compare commits
8 Commits
ec460faf02
...
f8c1ba4988
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c1ba4988 | ||
|
|
ae6473635d | ||
|
|
6b5aefd4c2 | ||
|
|
1ea0258393 | ||
|
|
09b49f096c | ||
|
|
18cacb33ee | ||
|
|
d850d00bab | ||
|
|
579b5f1a04 |
@@ -35,6 +35,20 @@ These paths intentionally stay flat (no `/environments/{envSlug}` prefix). Every
|
||||
|
||||
ClickHouse is shared across tenants. Every ClickHouse query must filter by `tenant_id` (from `CAMELEER_SERVER_TENANT_ID` env var, resolved via `TenantContext`/config) in addition to `environment`. New controllers added under `/environments/{envSlug}/...` must preserve this — the env filter from the path does not replace the tenant filter.
|
||||
|
||||
## User ID conventions
|
||||
|
||||
`users.user_id` stores the **bare** identifier:
|
||||
- Local users: `<username>` (e.g. `admin`, `alice`)
|
||||
- OIDC users: `oidc:<sub>` (e.g. `oidc:c7a93b…`)
|
||||
|
||||
JWT subjects carry a `user:` namespace prefix (`user:admin`, `user:oidc:<sub>`) so `JwtAuthenticationFilter` can distinguish user tokens from agent tokens. All three write paths upsert the **bare** form:
|
||||
|
||||
- `UiAuthController.login` — computes `userId = request.username()`, signs with `subject = "user:" + userId`.
|
||||
- `OidcAuthController.callback` — `userId = "oidc:" + oidcUser.subject()`, signs with `subject = "user:" + userId`.
|
||||
- `UserAdminController.createUser` — `userId = request.username()`.
|
||||
|
||||
Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `AlertSilenceController`, `OutboundConnectionAdminController`) strip `"user:"` from `SecurityContextHolder.authentication.name` before using it as an FK. All FKs to `users(user_id)` (e.g. `alert_rules.created_by`, `outbound_connections.created_by`, `alert_reads.user_id`, `user_roles.user_id`, `user_groups.user_id`) therefore reference the bare form. If you add a new controller that needs the acting user id for an FK insert, follow the same strip pattern.
|
||||
|
||||
## controller/ — REST endpoints
|
||||
|
||||
### Env-scoped (user-facing data & config)
|
||||
@@ -143,7 +157,8 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
|
||||
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+.
|
||||
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
|
||||
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
|
||||
- `OidcAuthController` — /api/v1/auth/oidc (login-uri, token-exchange, logout)
|
||||
- `UiAuthController` — `/api/v1/auth` (login, refresh, me). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup.
|
||||
- `OidcAuthController` — `/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form.
|
||||
- `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token
|
||||
- `OidcProviderHelper` — OIDC discovery, JWK source cache
|
||||
|
||||
@@ -168,7 +183,7 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
|
||||
- `crypto/SecretCipher` — AES-GCM symmetric cipher with key derived via HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1"). Ciphertext format: base64(IV(12 bytes) || GCM output with 128-bit tag). `encrypt` throws `IllegalStateException`; `decrypt` throws `IllegalArgumentException` on tamper/wrong-key/malformed.
|
||||
- `storage/PostgresOutboundConnectionRepository` — JdbcTemplate impl. `save()` upserts by id; JSONB serialization via ObjectMapper; UUID arrays via `ConnectionCallback`. Reads `created_by`/`updated_by` as String (= users.user_id TEXT).
|
||||
- `OutboundConnectionServiceImpl` — service layer. Tenant bound at construction via `cameleer.server.tenant.id` property. Uniqueness check via `findByName`. Narrowing-envs guard: rejects update that removes envs while rules reference the connection (rulesReferencing stubbed in Plan 01, wired in Plan 02). Delete guard: rejects if referenced by rules.
|
||||
- `controller/OutboundConnectionAdminController` — REST controller. Class-level `@PreAuthorize("hasRole('ADMIN')")` defaults; GETs relaxed to ADMIN|OPERATOR. Extracts acting user id from `SecurityContextHolder.authentication.name`, strips "user:" prefix. Audit via `AuditCategory.OUTBOUND_CONNECTION_CHANGE`.
|
||||
- `controller/OutboundConnectionAdminController` — REST controller. Class-level `@PreAuthorize("hasRole('ADMIN')")` defaults; GETs relaxed to ADMIN|OPERATOR. Resolves acting user id via the user-id convention (strip `"user:"` from `authentication.name` → matches `users.user_id` FK). Audit via `AuditCategory.OUTBOUND_CONNECTION_CHANGE`.
|
||||
- `dto/OutboundConnectionRequest` — Bean Validation: `@NotBlank` name, `@Pattern("^https://.+")` url, `@NotNull` method/tlsTrustMode/auth. Compact ctor throws `IllegalArgumentException` if TRUST_PATHS with empty paths list.
|
||||
- `dto/OutboundConnectionDto` — response DTO. `hmacSecretSet: boolean` instead of the ciphertext; `authKind: OutboundAuthKind` instead of the full auth config.
|
||||
- `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable).
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (8524 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/alerting-02/process/{processName}` — trace the full execution flow step by step
|
||||
3. `READ gitnexus://repo/cameleer-server/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/alerting-02/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/alerting-02/clusters` | All functional areas |
|
||||
| `gitnexus://repo/alerting-02/processes` | All execution flows |
|
||||
| `gitnexus://repo/alerting-02/process/{name}` | Step-by-step execution trace |
|
||||
| `gitnexus://repo/cameleer-server/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/cameleer-server/clusters` | All functional areas |
|
||||
| `gitnexus://repo/cameleer-server/processes` | All execution flows |
|
||||
| `gitnexus://repo/cameleer-server/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -51,7 +51,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
|
||||
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256.
|
||||
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server.
|
||||
- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys).
|
||||
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
||||
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`. `users.user_id` is the **bare** identifier — local users as `<username>`, OIDC users as `oidc:<sub>`. JWT `sub` carries the `user:` namespace prefix so `JwtAuthenticationFilter` can tell user tokens from agent tokens; write paths (`UiAuthController`, `OidcAuthController`, `UserAdminController`) all upsert unprefixed, and env-scoped read-path controllers strip the `user:` prefix before using the value as an FK to `users.user_id` / `user_roles.user_id`. Alerting / outbound FKs (`alert_rules.created_by`, `outbound_connections.created_by`, …) therefore all reference the bare form.
|
||||
- Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s
|
||||
|
||||
## Database Migrations
|
||||
@@ -97,7 +97,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (8524 symbols, 22174 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
@@ -113,7 +113,7 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/alerting-02/process/{processName}` — trace the full execution flow step by step
|
||||
3. `READ gitnexus://repo/cameleer-server/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
@@ -152,10 +152,10 @@ This project is indexed by GitNexus as **alerting-02** (7810 symbols, 20082 rela
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/alerting-02/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/alerting-02/clusters` | All functional areas |
|
||||
| `gitnexus://repo/alerting-02/processes` | All execution flows |
|
||||
| `gitnexus://repo/alerting-02/process/{name}` | Step-by-step execution trace |
|
||||
| `gitnexus://repo/cameleer-server/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/cameleer-server/clusters` | All functional areas |
|
||||
| `gitnexus://repo/cameleer-server/processes` | All execution flows |
|
||||
| `gitnexus://repo/cameleer-server/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
|
||||
@@ -63,9 +63,7 @@ public class AlertController {
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public UnreadCountResponse unreadCount(@EnvPath Environment env) {
|
||||
String userId = currentUserId();
|
||||
long count = inboxQuery.countUnread(env.id(), userId);
|
||||
return new UnreadCountResponse(count);
|
||||
return inboxQuery.countUnread(env.id(), currentUserId());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
package com.cameleer.server.app.alerting.dto;
|
||||
|
||||
public record UnreadCountResponse(long count) {}
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Response shape for {@code GET /alerts/unread-count}.
|
||||
* <p>
|
||||
* {@code total} is the sum of {@code bySeverity} values. The UI branches bell colour on
|
||||
* the highest severity present, so callers can inspect the map directly.
|
||||
*/
|
||||
public record UnreadCountResponse(long total, Map<AlertSeverity, Long> bySeverity) {
|
||||
|
||||
public UnreadCountResponse {
|
||||
// Defensive copy + fill in missing severities as 0 so the UI never sees null/undefined.
|
||||
EnumMap<AlertSeverity, Long> normalized = new EnumMap<>(AlertSeverity.class);
|
||||
for (AlertSeverity s : AlertSeverity.values()) normalized.put(s, 0L);
|
||||
if (bySeverity != null) bySeverity.forEach((k, v) -> normalized.put(k, v == null ? 0L : v));
|
||||
bySeverity = Map.copyOf(normalized);
|
||||
}
|
||||
|
||||
public static UnreadCountResponse from(Map<AlertSeverity, Long> counts) {
|
||||
long total = counts == null ? 0L
|
||||
: counts.values().stream().filter(v -> v != null).mapToLong(Long::longValue).sum();
|
||||
return new UnreadCountResponse(total, counts == null ? Map.of() : counts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.cameleer.server.app.alerting.notify;
|
||||
|
||||
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||
import com.cameleer.server.core.alerting.AlertInstance;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
import com.cameleer.server.core.rbac.RbacService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -17,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* <p>
|
||||
* {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role).
|
||||
* {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering
|
||||
* the database on every page render.
|
||||
* the database on every page render. The memo caches the full per-severity breakdown so
|
||||
* the UI can branch bell colour on the highest unread severity without a second call.
|
||||
*/
|
||||
@Component
|
||||
public class InAppInboxQuery {
|
||||
@@ -31,8 +34,8 @@ public class InAppInboxQuery {
|
||||
/** Cache key for the unread count memo. */
|
||||
private record Key(UUID envId, String userId) {}
|
||||
|
||||
/** Cache entry: cached count + expiry timestamp. */
|
||||
private record Entry(long count, Instant expiresAt) {}
|
||||
/** Cache entry: cached response + expiry timestamp. */
|
||||
private record Entry(UnreadCountResponse response, Instant expiresAt) {}
|
||||
|
||||
private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -57,20 +60,21 @@ public class InAppInboxQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of unread (un-acked) alert instances visible to the user.
|
||||
* Returns the unread (un-acked) alert count for the user, broken down by severity.
|
||||
* <p>
|
||||
* The result is memoized for 5 seconds per {@code (envId, userId)}.
|
||||
* Memoized for 5 seconds per {@code (envId, userId)}.
|
||||
*/
|
||||
public long countUnread(UUID envId, String userId) {
|
||||
public UnreadCountResponse countUnread(UUID envId, String userId) {
|
||||
Key key = new Key(envId, userId);
|
||||
Instant now = Instant.now(clock);
|
||||
Entry cached = memo.get(key);
|
||||
if (cached != null && now.isBefore(cached.expiresAt())) {
|
||||
return cached.count();
|
||||
return cached.response();
|
||||
}
|
||||
long count = instanceRepo.countUnreadForUser(envId, userId);
|
||||
memo.put(key, new Entry(count, now.plusMillis(MEMO_TTL_MS)));
|
||||
return count;
|
||||
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverityForUser(envId, userId);
|
||||
UnreadCountResponse response = UnreadCountResponse.from(bySeverity);
|
||||
memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS)));
|
||||
return response;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -118,18 +118,24 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countUnreadForUser(UUID environmentId, String userId) {
|
||||
public Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId) {
|
||||
String sql = """
|
||||
SELECT COUNT(*) FROM alert_instances ai
|
||||
SELECT ai.severity::text AS severity, COUNT(*) AS cnt
|
||||
FROM alert_instances ai
|
||||
WHERE ai.environment_id = ?
|
||||
AND ? = ANY(ai.target_user_ids)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM alert_reads ar
|
||||
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
|
||||
)
|
||||
GROUP BY ai.severity
|
||||
""";
|
||||
Long count = jdbc.queryForObject(sql, Long.class, environmentId, userId, userId);
|
||||
return count == null ? 0L : count;
|
||||
EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class);
|
||||
for (AlertSeverity s : AlertSeverity.values()) counts.put(s, 0L);
|
||||
jdbc.query(sql, rs -> {
|
||||
counts.put(AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt"));
|
||||
}, environmentId, userId, userId);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -93,7 +93,9 @@ public class UserAdminController {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));
|
||||
}
|
||||
String userId = "user:" + request.username();
|
||||
// DB key is the bare username (matches alert_rules.created_by FK shape used by
|
||||
// the env-scoped read-path controllers, which strip "user:" from JWT subjects).
|
||||
String userId = request.username();
|
||||
UserInfo user = new UserInfo(userId, "local",
|
||||
request.email() != null ? request.email() : "",
|
||||
request.displayName() != null ? request.displayName() : request.username(),
|
||||
@@ -215,9 +217,7 @@ public class UserAdminController {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
// Extract bare username from "user:username" format for policy check
|
||||
String username = userId.startsWith("user:") ? userId.substring(5) : userId;
|
||||
List<String> violations = PasswordPolicyValidator.validate(request.password(), username);
|
||||
List<String> violations = PasswordPolicyValidator.validate(request.password(), userId);
|
||||
if (!violations.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Password policy violation: " + String.join("; ", violations));
|
||||
|
||||
@@ -140,28 +140,29 @@ public class OidcAuthController {
|
||||
OidcTokenExchanger.OidcUserInfo oidcUser =
|
||||
tokenExchanger.exchange(request.code(), request.redirectUri());
|
||||
|
||||
String userId = "user:oidc:" + oidcUser.subject();
|
||||
// DB key is unprefixed (matches alert_rules.created_by FK shape used by the
|
||||
// env-scoped read-path controllers). JWT subject keeps the "user:" namespace
|
||||
// so JwtAuthenticationFilter can still distinguish user vs agent tokens.
|
||||
String userId = "oidc:" + oidcUser.subject();
|
||||
String subject = "user:" + userId;
|
||||
String issuerHost = URI.create(config.get().issuerUri()).getHost();
|
||||
String provider = "oidc:" + issuerHost;
|
||||
|
||||
// Check auto-signup gate: if disabled, user must already exist
|
||||
Optional<UserInfo> existingUser = userRepository.findById(userId);
|
||||
if (!config.get().autoSignup() && existingUser.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Account not provisioned. Contact your administrator.");
|
||||
}
|
||||
|
||||
// Upsert user (without roles -- roles are in user_roles table)
|
||||
userRepository.upsert(new UserInfo(
|
||||
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
|
||||
|
||||
// Apply claim mapping rules to assign managed roles/groups from JWT claims
|
||||
applyClaimMappings(userId, oidcUser.allClaims(), oidcUser.roles(), config.get());
|
||||
|
||||
List<String> roles = rbacService.getSystemRoleNames(userId);
|
||||
|
||||
String accessToken = jwtService.createAccessToken(userId, "user", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(userId, "user", roles);
|
||||
String accessToken = jwtService.createAccessToken(subject, "user", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(subject, "user", roles);
|
||||
|
||||
String displayName = oidcUser.name() != null && !oidcUser.name().isBlank()
|
||||
? oidcUser.name() : oidcUser.email();
|
||||
|
||||
@@ -77,27 +77,30 @@ public class UiAuthController {
|
||||
HttpServletRequest httpRequest) {
|
||||
String configuredUser = properties.getUiUser();
|
||||
String configuredPassword = properties.getUiPassword();
|
||||
String subject = "user:" + request.username();
|
||||
// The JWT subject carries a "user:" namespace prefix so the auth filter
|
||||
// can distinguish user vs agent tokens. The DB row keys (users.user_id,
|
||||
// user_roles.user_id, alert_rules.created_by FK, …) are the bare username:
|
||||
// every env-scoped controller strips the prefix on the read path via
|
||||
// stripSubjectPrefix(...), so the write path here must match.
|
||||
String userId = request.username();
|
||||
String subject = "user:" + userId;
|
||||
|
||||
// Check account lockout before attempting authentication
|
||||
if (userRepository.isLocked(subject)) {
|
||||
if (userRepository.isLocked(userId)) {
|
||||
auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest);
|
||||
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Account locked due to too many failed attempts. Try again later.");
|
||||
}
|
||||
|
||||
// Try env-var admin first
|
||||
boolean envMatch = configuredUser != null && !configuredUser.isBlank()
|
||||
&& configuredPassword != null && !configuredPassword.isBlank()
|
||||
&& configuredUser.equals(request.username())
|
||||
&& configuredPassword.equals(request.password());
|
||||
|
||||
if (!envMatch) {
|
||||
// Try per-user password
|
||||
Optional<String> hash = userRepository.getPasswordHash(subject);
|
||||
Optional<String> hash = userRepository.getPasswordHash(userId);
|
||||
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
|
||||
userRepository.recordFailedLogin(subject);
|
||||
userRepository.recordFailedLogin(userId);
|
||||
log.debug("UI login failed for user: {}", request.username());
|
||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
||||
@@ -105,23 +108,22 @@ public class UiAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
// Successful login — clear any failed attempt counter
|
||||
userRepository.clearFailedLogins(subject);
|
||||
userRepository.clearFailedLogins(userId);
|
||||
|
||||
if (envMatch) {
|
||||
// Env-var admin: upsert and ensure ADMIN role + Admins group
|
||||
// Env-var admin: upsert unprefixed and ensure ADMIN role + Admins group
|
||||
try {
|
||||
userRepository.upsert(new UserInfo(
|
||||
subject, "local", "", request.username(), Instant.now()));
|
||||
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
|
||||
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||
userId, "local", "", request.username(), Instant.now()));
|
||||
rbacService.assignRoleToUser(userId, SystemRole.ADMIN_ID);
|
||||
rbacService.addUserToGroup(userId, SystemRole.ADMINS_GROUP_ID);
|
||||
} catch (Exception e) {
|
||||
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);
|
||||
List<String> roles = rbacService.getSystemRoleNames(userId);
|
||||
if (roles.isEmpty()) {
|
||||
roles = List.of("VIEWER");
|
||||
}
|
||||
@@ -152,9 +154,10 @@ public class UiAuthController {
|
||||
String accessToken = jwtService.createAccessToken(result.subject(), "user", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(result.subject(), "user", roles);
|
||||
|
||||
String displayName = userRepository.findById(result.subject())
|
||||
String userId = stripSubjectPrefix(result.subject());
|
||||
String displayName = userRepository.findById(userId)
|
||||
.map(UserInfo::displayName)
|
||||
.orElse(result.subject());
|
||||
.orElse(userId);
|
||||
auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
|
||||
} catch (ResponseStatusException e) {
|
||||
@@ -173,13 +176,22 @@ public class UiAuthController {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
UserDetail detail = rbacService.getUser(authentication.getName());
|
||||
UserDetail detail = rbacService.getUser(stripSubjectPrefix(authentication.getName()));
|
||||
if (detail == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found");
|
||||
}
|
||||
return ResponseEntity.ok(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
|
||||
* just the bare username. FKs on {@code alert_rules.created_by},
|
||||
* {@code outbound_connections.created_by}, etc. reference the unprefixed row.
|
||||
*/
|
||||
private static String stripSubjectPrefix(String subject) {
|
||||
return subject != null && subject.startsWith("user:") ? subject.substring(5) : subject;
|
||||
}
|
||||
|
||||
public record LoginRequest(String username, String password) {}
|
||||
public record RefreshRequest(String refreshToken) {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.cameleer.server.app.alerting.notify;
|
||||
|
||||
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||
import com.cameleer.server.core.alerting.AlertInstance;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
import com.cameleer.server.core.rbac.GroupSummary;
|
||||
import com.cameleer.server.core.rbac.RbacService;
|
||||
import com.cameleer.server.core.rbac.RoleSummary;
|
||||
@@ -14,7 +16,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@@ -45,7 +49,6 @@ class InAppInboxQueryTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Build a Clock that delegates to the atomic counter so we can advance time precisely
|
||||
tickableClock = new Clock() {
|
||||
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@@ -54,8 +57,6 @@ class InAppInboxQueryTest {
|
||||
|
||||
query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock);
|
||||
|
||||
// RbacService stubs: return no groups/roles by default.
|
||||
// Lenient: countUnread tests don't invoke listInbox → stubs would otherwise be flagged unused.
|
||||
lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of());
|
||||
lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of());
|
||||
}
|
||||
@@ -83,75 +84,107 @@ class InAppInboxQueryTest {
|
||||
USER_ID, List.of("OPERATOR"), 20);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// countUnread — bySeverity shape
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void countUnread_totalIsSumOfBySeverityValues() {
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
.thenReturn(severities(4L, 2L, 1L));
|
||||
|
||||
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
|
||||
|
||||
assertThat(response.total()).isEqualTo(7L);
|
||||
assertThat(response.bySeverity())
|
||||
.containsEntry(AlertSeverity.CRITICAL, 4L)
|
||||
.containsEntry(AlertSeverity.WARNING, 2L)
|
||||
.containsEntry(AlertSeverity.INFO, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_fillsMissingSeveritiesWithZero() {
|
||||
// Repository returns only CRITICAL — WARNING/INFO must default to 0.
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
.thenReturn(Map.of(AlertSeverity.CRITICAL, 3L));
|
||||
|
||||
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
|
||||
|
||||
assertThat(response.total()).isEqualTo(3L);
|
||||
assertThat(response.bySeverity())
|
||||
.containsEntry(AlertSeverity.CRITICAL, 3L)
|
||||
.containsEntry(AlertSeverity.WARNING, 0L)
|
||||
.containsEntry(AlertSeverity.INFO, 0L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// countUnread — memoization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void countUnread_firstCallHitsRepository() {
|
||||
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(7L);
|
||||
|
||||
long count = query.countUnread(ENV_ID, USER_ID);
|
||||
|
||||
assertThat(count).isEqualTo(7L);
|
||||
verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_secondCallWithin5sUsesCache() {
|
||||
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(5L);
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
.thenReturn(severities(1L, 2L, 2L));
|
||||
|
||||
long first = query.countUnread(ENV_ID, USER_ID);
|
||||
// Advance time by 4 seconds — still within TTL
|
||||
UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
|
||||
nowMillis.addAndGet(4_000L);
|
||||
long second = query.countUnread(ENV_ID, USER_ID);
|
||||
UnreadCountResponse second = query.countUnread(ENV_ID, USER_ID);
|
||||
|
||||
assertThat(first).isEqualTo(5L);
|
||||
assertThat(second).isEqualTo(5L);
|
||||
// Repository must have been called exactly once
|
||||
verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID);
|
||||
assertThat(first.total()).isEqualTo(5L);
|
||||
assertThat(second.total()).isEqualTo(5L);
|
||||
verify(instanceRepo, times(1)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_callAfter5sRefreshesCache() {
|
||||
when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID))
|
||||
.thenReturn(3L) // first call
|
||||
.thenReturn(9L); // after cache expires
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID))
|
||||
.thenReturn(severities(1L, 1L, 1L)) // first call — total 3
|
||||
.thenReturn(severities(4L, 3L, 2L)); // after TTL — total 9
|
||||
|
||||
long first = query.countUnread(ENV_ID, USER_ID);
|
||||
|
||||
// Advance by exactly 5001 ms — TTL expired
|
||||
UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
|
||||
nowMillis.addAndGet(5_001L);
|
||||
long third = query.countUnread(ENV_ID, USER_ID);
|
||||
UnreadCountResponse third = query.countUnread(ENV_ID, USER_ID);
|
||||
|
||||
assertThat(first).isEqualTo(3L);
|
||||
assertThat(third).isEqualTo(9L);
|
||||
// Repository called twice: once on cold-miss, once after TTL expiry
|
||||
verify(instanceRepo, times(2)).countUnreadForUser(ENV_ID, USER_ID);
|
||||
assertThat(first.total()).isEqualTo(3L);
|
||||
assertThat(third.total()).isEqualTo(9L);
|
||||
verify(instanceRepo, times(2)).countUnreadBySeverityForUser(ENV_ID, USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_differentUsersDontShareCache() {
|
||||
when(instanceRepo.countUnreadForUser(ENV_ID, "alice")).thenReturn(2L);
|
||||
when(instanceRepo.countUnreadForUser(ENV_ID, "bob")).thenReturn(8L);
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "alice"))
|
||||
.thenReturn(severities(0L, 1L, 1L));
|
||||
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "bob"))
|
||||
.thenReturn(severities(2L, 2L, 4L));
|
||||
|
||||
long alice = query.countUnread(ENV_ID, "alice");
|
||||
long bob = query.countUnread(ENV_ID, "bob");
|
||||
|
||||
assertThat(alice).isEqualTo(2L);
|
||||
assertThat(bob).isEqualTo(8L);
|
||||
verify(instanceRepo).countUnreadForUser(ENV_ID, "alice");
|
||||
verify(instanceRepo).countUnreadForUser(ENV_ID, "bob");
|
||||
assertThat(query.countUnread(ENV_ID, "alice").total()).isEqualTo(2L);
|
||||
assertThat(query.countUnread(ENV_ID, "bob").total()).isEqualTo(8L);
|
||||
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "alice");
|
||||
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "bob");
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnread_differentEnvsDontShareCache() {
|
||||
UUID envA = UUID.randomUUID();
|
||||
UUID envB = UUID.randomUUID();
|
||||
when(instanceRepo.countUnreadForUser(envA, USER_ID)).thenReturn(1L);
|
||||
when(instanceRepo.countUnreadForUser(envB, USER_ID)).thenReturn(4L);
|
||||
when(instanceRepo.countUnreadBySeverityForUser(envA, USER_ID))
|
||||
.thenReturn(severities(0L, 0L, 1L));
|
||||
when(instanceRepo.countUnreadBySeverityForUser(envB, USER_ID))
|
||||
.thenReturn(severities(1L, 1L, 2L));
|
||||
|
||||
assertThat(query.countUnread(envA, USER_ID)).isEqualTo(1L);
|
||||
assertThat(query.countUnread(envB, USER_ID)).isEqualTo(4L);
|
||||
assertThat(query.countUnread(envA, USER_ID).total()).isEqualTo(1L);
|
||||
assertThat(query.countUnread(envB, USER_ID).total()).isEqualTo(4L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static Map<AlertSeverity, Long> severities(long critical, long warning, long info) {
|
||||
EnumMap<AlertSeverity, Long> m = new EnumMap<>(AlertSeverity.class);
|
||||
m.put(AlertSeverity.CRITICAL, critical);
|
||||
m.put(AlertSeverity.WARNING, warning);
|
||||
m.put(AlertSeverity.INFO, info);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,20 +108,51 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadForUser_decreasesAfterMarkRead() {
|
||||
void countUnreadBySeverityForUser_decreasesAfterMarkRead() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
long before = repo.countUnreadForUser(envId, userId);
|
||||
assertThat(before).isEqualTo(1L);
|
||||
var before = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
assertThat(before)
|
||||
.containsEntry(AlertSeverity.WARNING, 1L)
|
||||
.containsEntry(AlertSeverity.CRITICAL, 0L)
|
||||
.containsEntry(AlertSeverity.INFO, 0L);
|
||||
|
||||
// Insert read record directly (AlertReadRepository not yet wired in this test)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
userId, inst.id());
|
||||
|
||||
long after = repo.countUnreadForUser(envId, userId);
|
||||
assertThat(after).isEqualTo(0L);
|
||||
var after = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
assertThat(after.values()).allMatch(v -> v == 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadBySeverityForUser_groupsBySeverity() {
|
||||
// Each open instance needs its own rule to satisfy V13's unique partial index.
|
||||
UUID critRule = seedRuleWithSeverity("crit", AlertSeverity.CRITICAL);
|
||||
UUID warnRule = seedRuleWithSeverity("warn", AlertSeverity.WARNING);
|
||||
UUID infoRule = seedRuleWithSeverity("info", AlertSeverity.INFO);
|
||||
|
||||
repo.save(newInstance(critRule, AlertSeverity.CRITICAL, List.of(userId), List.of(), List.of()));
|
||||
repo.save(newInstance(warnRule, AlertSeverity.WARNING, List.of(userId), List.of(), List.of()));
|
||||
repo.save(newInstance(infoRule, AlertSeverity.INFO, List.of(userId), List.of(), List.of()));
|
||||
|
||||
var counts = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
|
||||
assertThat(counts)
|
||||
.containsEntry(AlertSeverity.CRITICAL, 1L)
|
||||
.containsEntry(AlertSeverity.WARNING, 1L)
|
||||
.containsEntry(AlertSeverity.INFO, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadBySeverityForUser_emptyMapStillHasAllKeys() {
|
||||
// No instances saved — every severity must still be present with value 0
|
||||
// so callers never deal with null/missing keys.
|
||||
var counts = repo.countUnreadBySeverityForUser(envId, userId);
|
||||
assertThat(counts).hasSize(3);
|
||||
assertThat(counts.values()).allMatch(v -> v == 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -228,15 +259,34 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
List<String> userIds,
|
||||
List<UUID> groupIds,
|
||||
List<String> roleNames) {
|
||||
return newInstance(ruleId, AlertSeverity.WARNING, userIds, groupIds, roleNames);
|
||||
}
|
||||
|
||||
private AlertInstance newInstance(UUID ruleId,
|
||||
AlertSeverity severity,
|
||||
List<String> userIds,
|
||||
List<UUID> groupIds,
|
||||
List<String> roleNames) {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), ruleId, Map.of(), envId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
AlertState.FIRING, severity,
|
||||
Instant.now(), null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of(), "title", "message",
|
||||
userIds, groupIds, roleNames);
|
||||
}
|
||||
|
||||
/** Inserts a minimal alert_rule with the given severity. */
|
||||
private UUID seedRuleWithSeverity(String name, AlertSeverity severity) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, ?, ?::severity_enum, 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||
id, envId, name + "-" + id, severity.name());
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Inserts a minimal alert_rule with re_notify_minutes=0 and returns its id. */
|
||||
private UUID seedRule(String name) {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cameleer.server.core.alerting;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -14,7 +15,14 @@ public interface AlertInstanceRepository {
|
||||
String userId,
|
||||
List<String> userRoleNames,
|
||||
int limit);
|
||||
long countUnreadForUser(UUID environmentId, String userId);
|
||||
|
||||
/**
|
||||
* Count unread alert instances for the user, grouped by severity.
|
||||
* <p>
|
||||
* Always returns a map with an entry for every {@link AlertSeverity} (value 0 if no rows),
|
||||
* so callers never need null-checks. Total unread count is the sum of the values.
|
||||
*/
|
||||
Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId);
|
||||
void ack(UUID id, String userId, Instant when);
|
||||
void resolve(UUID id, Instant when);
|
||||
void markSilenced(UUID id, boolean silenced);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
-- Dev-stack seed: pre-create the `admin` user row without the `user:` prefix.
|
||||
--
|
||||
-- Why: the UI login controller stores the local admin as `user_id='user:admin'`
|
||||
-- (JWT `sub` format), but the alerting + outbound controllers resolve the FK
|
||||
-- via `authentication.name` with the `user:` prefix stripped, i.e. `admin`.
|
||||
-- In k8s these controllers happily insert `admin` because production admins are
|
||||
-- provisioned through the admin API with unprefixed user_ids. In the local
|
||||
-- docker stack there's no such provisioning step, so the FK check fails with
|
||||
-- "alert_rules_created_by_fkey violation" on the first rule create.
|
||||
--
|
||||
-- Seeding a row with `user_id='admin'` here bridges the gap so E2E smokes,
|
||||
-- API probes, and manual dev sessions can create alerting rows straight away.
|
||||
-- Flyway owns the schema in tenant_default; this script only INSERTs idempotently
|
||||
-- and is gated on the schema existing.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schema_exists bool;
|
||||
table_exists bool;
|
||||
BEGIN
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.schemata WHERE schema_name = 'tenant_default'
|
||||
) INTO schema_exists;
|
||||
IF NOT schema_exists THEN
|
||||
RAISE NOTICE 'tenant_default schema not yet migrated — skipping admin seed (Flyway will run on server start)';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'tenant_default' AND table_name = 'users'
|
||||
) INTO table_exists;
|
||||
IF NOT table_exists THEN
|
||||
RAISE NOTICE 'tenant_default.users not yet migrated — skipping admin seed';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO tenant_default.users (user_id, provider, email, display_name)
|
||||
VALUES ('admin', 'local', '', 'admin')
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
END $$;
|
||||
@@ -130,25 +130,6 @@ services:
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# Run-once seeder: waits for the server to be healthy (i.e. Flyway migrations
|
||||
# finished) and inserts a `user_id='admin'` row (without the `user:` prefix)
|
||||
# so alerting-controller FKs succeed. See deploy/docker/postgres-init.sql for
|
||||
# the full rationale. Idempotent — exits 0 if the row already exists.
|
||||
cameleer-seed:
|
||||
image: postgres:16
|
||||
container_name: cameleer-seed
|
||||
depends_on:
|
||||
cameleer-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: cameleer_dev
|
||||
volumes:
|
||||
- ./deploy/docker/postgres-init.sql:/seed.sql:ro
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
- "psql -h cameleer-postgres -U cameleer -d cameleer -v ON_ERROR_STOP=1 -f /seed.sql"
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
cameleer-pgdata:
|
||||
cameleer-chdata:
|
||||
|
||||
@@ -32,7 +32,7 @@ mvn clean compile # confirm Plan 01 code compiles as baseline
|
||||
|---|---|
|
||||
| `AlertingProperties.java` | Not here — see app module. |
|
||||
| `AlertRule.java` | Immutable record: id, environmentId, name, description, severity, enabled, conditionKind, condition, evaluationIntervalSeconds, forDurationSeconds, reNotifyMinutes, notificationTitleTmpl, notificationMessageTmpl, webhooks, targets, nextEvaluationAt, claimedBy, claimedUntil, evalState, audit fields. |
|
||||
| `AlertCondition.java` | Sealed interface; Jackson DEDUCTION polymorphism root. |
|
||||
| `AlertCondition.java` | Sealed interface; Jackson `kind`-based polymorphism root (Id.NAME + EXISTING_PROPERTY). |
|
||||
| `RouteMetricCondition.java` | Record: scope, metric, comparator, threshold, windowSeconds. |
|
||||
| `ExchangeMatchCondition.java` | Record: scope, filter, fireMode, threshold, windowSeconds, perExchangeLingerSeconds. |
|
||||
| `AgentStateCondition.java` | Record: scope, state, forSeconds. |
|
||||
@@ -126,7 +126,7 @@ mvn clean compile # confirm Plan 01 code compiles as baseline
|
||||
- **One commit per task.** Commit messages: `feat(alerting): …`, `test(alerting): …`, `fix(alerting): …`, `chore(alerting): …`, `docs(alerting): …`.
|
||||
- **Tenant invariant.** Every ClickHouse query and Postgres table referencing observability data filters by `tenantId` (injected via `AlertingBeanConfig` from `cameleer.server.tenant.id`).
|
||||
- **No `FINAL`** on the two new CH count methods — alerting tolerates brief duplicate counts.
|
||||
- **Jackson polymorphism** via `@JsonTypeInfo(use = DEDUCTION)` with `@JsonSubTypes` on `AlertCondition`.
|
||||
- **Jackson polymorphism** via `@JsonTypeInfo(use = Id.NAME, property = "kind", include = EXISTING_PROPERTY)` with `@JsonSubTypes` on `AlertCondition`.
|
||||
- **Pure `core/`, Spring-only in `app/`.** No `@Component`, `@Service`, or `@Scheduled` annotations in `cameleer-server-core`.
|
||||
- **Claim polling.** `FOR UPDATE SKIP LOCKED` + `claimed_by` / `claimed_until` with 30 s TTL.
|
||||
- **Instance id** for claim ownership: use `InetAddress.getLocalHost().getHostName() + ":" + processPid()`; exposed as a bean `"alertingInstanceId"` of type `String`.
|
||||
@@ -403,7 +403,7 @@ git commit -m "feat(alerting): add ALERT_RULE_CHANGE + ALERT_SILENCE_CHANGE audi
|
||||
|
||||
## Phase 2 — Core domain model
|
||||
|
||||
Each task in this phase adds a small, focused set of pure-Java records and enums under `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/`. All records use canonical constructors with explicit `@NotNull`-style defensive copying only for mutable collections (`List.copyOf`, `Map.copyOf`). Jackson polymorphism is handled by `@JsonTypeInfo(use = DEDUCTION)` on `AlertCondition`.
|
||||
Each task in this phase adds a small, focused set of pure-Java records and enums under `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/`. All records use canonical constructors with explicit `@NotNull`-style defensive copying only for mutable collections (`List.copyOf`, `Map.copyOf`). Jackson polymorphism is handled by `@JsonTypeInfo(use = Id.NAME, property = "kind", include = EXISTING_PROPERTY)` on `AlertCondition` — the subtype is read from the existing `kind` field each record exposes.
|
||||
|
||||
### Task 3: Enums + `AlertScope`
|
||||
|
||||
@@ -606,14 +606,15 @@ package com.cameleer.server.core.alerting;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind",
|
||||
include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(RouteMetricCondition.class),
|
||||
@JsonSubTypes.Type(ExchangeMatchCondition.class),
|
||||
@JsonSubTypes.Type(AgentStateCondition.class),
|
||||
@JsonSubTypes.Type(DeploymentStateCondition.class),
|
||||
@JsonSubTypes.Type(LogPatternCondition.class),
|
||||
@JsonSubTypes.Type(JvmMetricCondition.class)
|
||||
@JsonSubTypes.Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"),
|
||||
@JsonSubTypes.Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"),
|
||||
@JsonSubTypes.Type(value = AgentStateCondition.class, name = "AGENT_STATE"),
|
||||
@JsonSubTypes.Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"),
|
||||
@JsonSubTypes.Type(value = LogPatternCondition.class, name = "LOG_PATTERN"),
|
||||
@JsonSubTypes.Type(value = JvmMetricCondition.class, name = "JVM_METRIC")
|
||||
})
|
||||
public sealed interface AlertCondition permits
|
||||
RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition,
|
||||
|
||||
@@ -286,7 +286,7 @@ CREATE TABLE alert_rules (
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
|
||||
condition_kind condition_kind_enum NOT NULL,
|
||||
condition jsonb NOT NULL, -- sealed-subtype payload, Jackson-DEDUCTION polymorphic
|
||||
condition jsonb NOT NULL, -- sealed-subtype payload, Jackson polymorphic on `kind`
|
||||
|
||||
evaluation_interval_seconds int NOT NULL DEFAULT 60 CHECK (evaluation_interval_seconds >= 5),
|
||||
for_duration_seconds int NOT NULL DEFAULT 0 CHECK (for_duration_seconds >= 0),
|
||||
@@ -423,14 +423,15 @@ outbound_connections (delete) — blocked by FK from rules.webhooks JSONB
|
||||
### Jackson polymorphism for conditions
|
||||
|
||||
```java
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind",
|
||||
include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
|
||||
@JsonSubTypes({
|
||||
@Type(RouteMetricCondition.class),
|
||||
@Type(ExchangeMatchCondition.class),
|
||||
@Type(AgentStateCondition.class),
|
||||
@Type(DeploymentStateCondition.class),
|
||||
@Type(LogPatternCondition.class),
|
||||
@Type(JvmMetricCondition.class),
|
||||
@Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"),
|
||||
@Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"),
|
||||
@Type(value = AgentStateCondition.class, name = "AGENT_STATE"),
|
||||
@Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"),
|
||||
@Type(value = LogPatternCondition.class, name = "LOG_PATTERN"),
|
||||
@Type(value = JvmMetricCondition.class, name = "JVM_METRIC"),
|
||||
})
|
||||
public sealed interface AlertCondition permits
|
||||
RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition,
|
||||
@@ -439,37 +440,40 @@ public sealed interface AlertCondition permits
|
||||
}
|
||||
```
|
||||
|
||||
Jackson deduces the subtype from the set of present fields. Bean Validation (`@Valid`) on each record validates at the controller boundary.
|
||||
Each payload carries its own `kind` field, which Jackson reads (`EXISTING_PROPERTY`) to pick the subtype and the record still exposes as `ConditionKind kind()`. Bean Validation (`@Valid`) on each record validates at the controller boundary.
|
||||
|
||||
Example condition payloads:
|
||||
|
||||
```json
|
||||
// ROUTE_METRIC
|
||||
{ "scope": {"appSlug":"orders","routeId":"route-1"},
|
||||
{ "kind": "ROUTE_METRIC",
|
||||
"scope": {"appSlug":"orders","routeId":"route-1"},
|
||||
"metric": "P99_LATENCY_MS", "comparator": "GT", "threshold": 2000, "windowSeconds": 300 }
|
||||
|
||||
// EXCHANGE_MATCH — PER_EXCHANGE
|
||||
{ "scope": {"appSlug":"orders"},
|
||||
{ "kind": "EXCHANGE_MATCH",
|
||||
"scope": {"appSlug":"orders"},
|
||||
"filter": {"status":"FAILED","attributes":{"type":"payment"}},
|
||||
"fireMode": "PER_EXCHANGE", "perExchangeLingerSeconds": 300 }
|
||||
|
||||
// EXCHANGE_MATCH — COUNT_IN_WINDOW
|
||||
{ "scope": {"appSlug":"orders"},
|
||||
{ "kind": "EXCHANGE_MATCH",
|
||||
"scope": {"appSlug":"orders"},
|
||||
"filter": {"status":"FAILED"},
|
||||
"fireMode": "COUNT_IN_WINDOW", "threshold": 5, "windowSeconds": 900 }
|
||||
|
||||
// AGENT_STATE
|
||||
{ "scope": {"appSlug":"orders"}, "state": "DEAD", "forSeconds": 60 }
|
||||
{ "kind": "AGENT_STATE", "scope": {"appSlug":"orders"}, "state": "DEAD", "forSeconds": 60 }
|
||||
|
||||
// DEPLOYMENT_STATE
|
||||
{ "scope": {"appSlug":"orders"}, "states": ["FAILED","DEGRADED"] }
|
||||
{ "kind": "DEPLOYMENT_STATE", "scope": {"appSlug":"orders"}, "states": ["FAILED","DEGRADED"] }
|
||||
|
||||
// LOG_PATTERN
|
||||
{ "scope": {"appSlug":"orders"}, "level": "ERROR",
|
||||
{ "kind": "LOG_PATTERN", "scope": {"appSlug":"orders"}, "level": "ERROR",
|
||||
"pattern": "TimeoutException", "threshold": 5, "windowSeconds": 900 }
|
||||
|
||||
// JVM_METRIC
|
||||
{ "scope": {"appSlug":"orders"}, "metric": "heap_used_percent",
|
||||
{ "kind": "JVM_METRIC", "scope": {"appSlug":"orders"}, "metric": "heap_used_percent",
|
||||
"aggregation": "MAX", "comparator": "GT", "threshold": 90, "windowSeconds": 300 }
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
5
ui/src/api/schema.d.ts
vendored
5
ui/src/api/schema.d.ts
vendored
@@ -3257,7 +3257,10 @@ export interface components {
|
||||
};
|
||||
UnreadCountResponse: {
|
||||
/** Format: int64 */
|
||||
count?: number;
|
||||
total?: number;
|
||||
bySeverity?: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
/** @description Agent instance summary with runtime metrics */
|
||||
AgentInstanceResponse: {
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--error);
|
||||
color: var(--bg);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.badgeCritical { background: var(--error); }
|
||||
.badgeWarning { background: var(--amber); }
|
||||
.badgeInfo { background: var(--muted); }
|
||||
|
||||
@@ -19,6 +19,13 @@ function wrapper({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function mockResponse(total: number, bySeverity: Record<string, number> = {}) {
|
||||
(apiClient.GET as any).mockResolvedValue({
|
||||
data: { total, bySeverity },
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -26,22 +33,37 @@ describe('NotificationBell', () => {
|
||||
});
|
||||
|
||||
it('renders bell with no badge when zero unread', async () => {
|
||||
(apiClient.GET as any).mockResolvedValue({
|
||||
data: { count: 0 },
|
||||
error: null,
|
||||
});
|
||||
mockResponse(0, { CRITICAL: 0, WARNING: 0, INFO: 0 });
|
||||
render(<NotificationBell />, { wrapper });
|
||||
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
|
||||
// Badge is only rendered when count > 0; no numeric text should appear.
|
||||
expect(screen.queryByText(/^\d+$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows unread count badge when unread alerts exist', async () => {
|
||||
(apiClient.GET as any).mockResolvedValue({
|
||||
data: { count: 3 },
|
||||
error: null,
|
||||
});
|
||||
it('shows unread total in badge', async () => {
|
||||
mockResponse(3, { CRITICAL: 1, WARNING: 2, INFO: 0 });
|
||||
render(<NotificationBell />, { wrapper });
|
||||
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('colours badge as CRITICAL when any critical unread present', async () => {
|
||||
mockResponse(5, { CRITICAL: 1, WARNING: 4, INFO: 0 });
|
||||
render(<NotificationBell />, { wrapper });
|
||||
const badge = await screen.findByText('5');
|
||||
expect(badge.className).toMatch(/badgeCritical/);
|
||||
});
|
||||
|
||||
it('colours badge as WARNING when only warnings+info unread', async () => {
|
||||
mockResponse(3, { CRITICAL: 0, WARNING: 2, INFO: 1 });
|
||||
render(<NotificationBell />, { wrapper });
|
||||
const badge = await screen.findByText('3');
|
||||
expect(badge.className).toMatch(/badgeWarning/);
|
||||
expect(badge.className).not.toMatch(/badgeCritical/);
|
||||
});
|
||||
|
||||
it('colours badge as INFO when only info unread', async () => {
|
||||
mockResponse(2, { CRITICAL: 0, WARNING: 0, INFO: 2 });
|
||||
render(<NotificationBell />, { wrapper });
|
||||
const badge = await screen.findByText('2');
|
||||
expect(badge.className).toMatch(/badgeInfo/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,33 +6,33 @@ import css from './NotificationBell.module.css';
|
||||
|
||||
/**
|
||||
* Global notification bell shown in the layout header. Links to the alerts
|
||||
* inbox and renders a badge with the unread-alert count for the currently
|
||||
* selected environment.
|
||||
*
|
||||
* Polling pause when the tab is hidden is handled by `useUnreadCount`'s
|
||||
* `refetchIntervalInBackground: false`; no separate visibility subscription
|
||||
* is needed. If per-severity coloring (spec §13) is re-introduced, the
|
||||
* backend `UnreadCountResponse` must grow a `bySeverity` map.
|
||||
* inbox and renders a badge coloured by the highest unread severity
|
||||
* (CRITICAL > WARNING > INFO) — matches the sidebar SeverityBadge palette.
|
||||
*/
|
||||
export function NotificationBell() {
|
||||
const env = useSelectedEnv();
|
||||
const { data } = useUnreadCount();
|
||||
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
if (!env) return null;
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const bySeverity = data?.bySeverity ?? {};
|
||||
const severityClass =
|
||||
(bySeverity.CRITICAL ?? 0) > 0 ? css.badgeCritical
|
||||
: (bySeverity.WARNING ?? 0) > 0 ? css.badgeWarning
|
||||
: css.badgeInfo;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/alerts/inbox"
|
||||
role="button"
|
||||
aria-label={`Notifications (${count} unread)`}
|
||||
aria-label={`Notifications (${total} unread)`}
|
||||
className={css.bell}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{count > 0 && (
|
||||
<span className={css.badge} aria-hidden>
|
||||
{count > 99 ? '99+' : count}
|
||||
{total > 0 && (
|
||||
<span className={`${css.badge} ${severityClass}`} aria-hidden>
|
||||
{total > 99 ? '99+' : total}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePageVisible } from './usePageVisible';
|
||||
|
||||
describe('usePageVisible', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true when visible, false when hidden', () => {
|
||||
const { result } = renderHook(() => usePageVisible());
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Tracks Page Visibility API state for the current document.
|
||||
*
|
||||
* Returns `true` when the tab is visible, `false` when hidden. Useful for
|
||||
* pausing work (polling, animations, expensive DOM effects) while the tab
|
||||
* is backgrounded. SSR-safe: defaults to `true` when `document` is undefined.
|
||||
*/
|
||||
export function usePageVisible(): boolean {
|
||||
const [visible, setVisible] = useState(() =>
|
||||
typeof document === 'undefined' ? true : document.visibilityState === 'visible',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', onChange);
|
||||
return () => document.removeEventListener('visibilitychange', onChange);
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
Reference in New Issue
Block a user