tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Migration: 20260129_001_create_identity_watchlist
|
||||
-- Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
-- Task: WATCH-004
|
||||
-- Description: Creates identity watchlist and alert deduplication tables.
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Watchlist entries table
|
||||
CREATE TABLE IF NOT EXISTS attestor.identity_watchlist (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'Tenant',
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Identity matching fields (at least one required)
|
||||
issuer TEXT,
|
||||
subject_alternative_name TEXT,
|
||||
key_id TEXT,
|
||||
match_mode TEXT NOT NULL DEFAULT 'Exact',
|
||||
|
||||
-- Alert configuration
|
||||
severity TEXT NOT NULL DEFAULT 'Warning',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
channel_overrides JSONB,
|
||||
suppress_duplicates_minutes INT NOT NULL DEFAULT 60,
|
||||
|
||||
-- Metadata
|
||||
tags TEXT[],
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT chk_at_least_one_identity CHECK (
|
||||
issuer IS NOT NULL OR
|
||||
subject_alternative_name IS NOT NULL OR
|
||||
key_id IS NOT NULL
|
||||
),
|
||||
CONSTRAINT chk_scope_valid CHECK (scope IN ('Tenant', 'Global', 'System')),
|
||||
CONSTRAINT chk_match_mode_valid CHECK (match_mode IN ('Exact', 'Prefix', 'Glob', 'Regex')),
|
||||
CONSTRAINT chk_severity_valid CHECK (severity IN ('Info', 'Warning', 'Critical')),
|
||||
CONSTRAINT chk_suppress_duplicates_positive CHECK (suppress_duplicates_minutes >= 1)
|
||||
);
|
||||
|
||||
-- Performance indexes for active entry lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_tenant_enabled
|
||||
ON attestor.identity_watchlist(tenant_id)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_scope_enabled
|
||||
ON attestor.identity_watchlist(scope)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_issuer
|
||||
ON attestor.identity_watchlist(issuer)
|
||||
WHERE enabled = TRUE AND issuer IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_san
|
||||
ON attestor.identity_watchlist(subject_alternative_name)
|
||||
WHERE enabled = TRUE AND subject_alternative_name IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_keyid
|
||||
ON attestor.identity_watchlist(key_id)
|
||||
WHERE enabled = TRUE AND key_id IS NOT NULL;
|
||||
|
||||
-- Alert deduplication table
|
||||
CREATE TABLE IF NOT EXISTS attestor.identity_alert_dedup (
|
||||
watchlist_id UUID NOT NULL,
|
||||
identity_hash TEXT NOT NULL,
|
||||
last_alert_at TIMESTAMPTZ NOT NULL,
|
||||
alert_count INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (watchlist_id, identity_hash)
|
||||
);
|
||||
|
||||
-- Index for cleanup
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_dedup_last_alert
|
||||
ON attestor.identity_alert_dedup(last_alert_at);
|
||||
|
||||
-- Comment documentation
|
||||
COMMENT ON TABLE attestor.identity_watchlist IS
|
||||
'Watchlist entries for monitoring signing identity appearances in transparency logs.';
|
||||
|
||||
COMMENT ON COLUMN attestor.identity_watchlist.scope IS
|
||||
'Visibility scope: Tenant (owning tenant only), Global (all tenants), System (read-only).';
|
||||
|
||||
COMMENT ON COLUMN attestor.identity_watchlist.match_mode IS
|
||||
'Pattern matching mode: Exact, Prefix, Glob, or Regex.';
|
||||
|
||||
COMMENT ON COLUMN attestor.identity_watchlist.suppress_duplicates_minutes IS
|
||||
'Deduplication window in minutes. Alerts for same identity within window are suppressed.';
|
||||
|
||||
COMMENT ON TABLE attestor.identity_alert_dedup IS
|
||||
'Tracks alert deduplication state to prevent alert storms.';
|
||||
@@ -0,0 +1,414 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresWatchlistRepository.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-004
|
||||
// Description: PostgreSQL implementation of watchlist repository.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Watchlist;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the watchlist repository with caching.
|
||||
/// </summary>
|
||||
public sealed class PostgresWatchlistRepository : IWatchlistRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresWatchlistRepository> _logger;
|
||||
private readonly ConcurrentDictionary<string, CachedEntries> _cache = new();
|
||||
private readonly TimeSpan _cacheTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public PostgresWatchlistRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresWatchlistRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WatchedIdentity?> GetAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapToEntry(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WatchedIdentity>> ListAsync(
|
||||
string tenantId,
|
||||
bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = includeGlobal
|
||||
? """
|
||||
SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE tenant_id = @tenantId OR scope IN ('Global', 'System')
|
||||
ORDER BY display_name
|
||||
"""
|
||||
: """
|
||||
SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE tenant_id = @tenantId
|
||||
ORDER BY display_name
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
var entries = new List<WatchedIdentity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
entries.Add(MapToEntry(reader));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<WatchedIdentity>> GetActiveForMatchingAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check cache first
|
||||
if (_cache.TryGetValue(tenantId, out var cached) &&
|
||||
cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cached.Entries;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE enabled = TRUE
|
||||
AND (tenant_id = @tenantId OR scope IN ('Global', 'System'))
|
||||
ORDER BY id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
var entries = new List<WatchedIdentity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
entries.Add(MapToEntry(reader));
|
||||
}
|
||||
|
||||
// Update cache
|
||||
_cache[tenantId] = new CachedEntries(entries, DateTimeOffset.UtcNow.Add(_cacheTimeout));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WatchedIdentity> UpsertAsync(
|
||||
WatchedIdentity entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO attestor.identity_watchlist (
|
||||
id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
) VALUES (
|
||||
@id, @tenantId, @scope, @displayName, @description,
|
||||
@issuer, @san, @keyId, @matchMode,
|
||||
@severity, @enabled, @channelOverrides, @suppressMinutes,
|
||||
@tags, @createdAt, @updatedAt, @createdBy, @updatedBy
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description,
|
||||
issuer = EXCLUDED.issuer,
|
||||
subject_alternative_name = EXCLUDED.subject_alternative_name,
|
||||
key_id = EXCLUDED.key_id,
|
||||
match_mode = EXCLUDED.match_mode,
|
||||
severity = EXCLUDED.severity,
|
||||
enabled = EXCLUDED.enabled,
|
||||
channel_overrides = EXCLUDED.channel_overrides,
|
||||
suppress_duplicates_minutes = EXCLUDED.suppress_duplicates_minutes,
|
||||
tags = EXCLUDED.tags,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
RETURNING id, tenant_id, scope, display_name, description,
|
||||
issuer, subject_alternative_name, key_id, match_mode,
|
||||
severity, enabled, channel_overrides, suppress_duplicates_minutes,
|
||||
tags, created_at, updated_at, created_by, updated_by
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", entry.Id);
|
||||
cmd.Parameters.AddWithValue("tenantId", entry.TenantId);
|
||||
cmd.Parameters.AddWithValue("scope", entry.Scope.ToString());
|
||||
cmd.Parameters.AddWithValue("displayName", entry.DisplayName);
|
||||
cmd.Parameters.AddWithValue("description", (object?)entry.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("issuer", (object?)entry.Issuer ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("san", (object?)entry.SubjectAlternativeName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("keyId", (object?)entry.KeyId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("matchMode", entry.MatchMode.ToString());
|
||||
cmd.Parameters.AddWithValue("severity", entry.Severity.ToString());
|
||||
cmd.Parameters.AddWithValue("enabled", entry.Enabled);
|
||||
cmd.Parameters.AddWithValue("channelOverrides",
|
||||
NpgsqlDbType.Jsonb,
|
||||
entry.ChannelOverrides is { Count: > 0 }
|
||||
? System.Text.Json.JsonSerializer.Serialize(entry.ChannelOverrides)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("suppressMinutes", entry.SuppressDuplicatesMinutes);
|
||||
cmd.Parameters.AddWithValue("tags",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
entry.Tags is { Count: > 0 } ? entry.Tags.ToArray() : Array.Empty<string>());
|
||||
cmd.Parameters.AddWithValue("createdAt", entry.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updatedAt", entry.UpdatedAt);
|
||||
cmd.Parameters.AddWithValue("createdBy", entry.CreatedBy);
|
||||
cmd.Parameters.AddWithValue("updatedBy", entry.UpdatedBy);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
// Invalidate cache
|
||||
InvalidateCache(entry.TenantId);
|
||||
return MapToEntry(reader);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Upsert did not return the expected row");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(
|
||||
Guid id,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM attestor.identity_watchlist
|
||||
WHERE id = @id AND (tenant_id = @tenantId OR @tenantId = 'system-admin')
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
InvalidateCache(tenantId);
|
||||
}
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM attestor.identity_watchlist
|
||||
WHERE tenant_id = @tenantId OR scope IN ('Global', 'System')
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private void InvalidateCache(string tenantId)
|
||||
{
|
||||
_cache.TryRemove(tenantId, out _);
|
||||
}
|
||||
|
||||
private static WatchedIdentity MapToEntry(NpgsqlDataReader reader)
|
||||
{
|
||||
var channelOverridesJson = reader.IsDBNull(reader.GetOrdinal("channel_overrides"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("channel_overrides"));
|
||||
|
||||
IReadOnlyList<string>? channelOverrides = null;
|
||||
if (!string.IsNullOrEmpty(channelOverridesJson))
|
||||
{
|
||||
channelOverrides = System.Text.Json.JsonSerializer.Deserialize<List<string>>(channelOverridesJson);
|
||||
}
|
||||
|
||||
var tagsOrdinal = reader.GetOrdinal("tags");
|
||||
IReadOnlyList<string>? tags = reader.IsDBNull(tagsOrdinal)
|
||||
? null
|
||||
: (string[])reader.GetValue(tagsOrdinal);
|
||||
|
||||
return new WatchedIdentity
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Scope = Enum.Parse<WatchlistScope>(reader.GetString(reader.GetOrdinal("scope"))),
|
||||
DisplayName = reader.GetString(reader.GetOrdinal("display_name")),
|
||||
Description = reader.IsDBNull(reader.GetOrdinal("description"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("description")),
|
||||
Issuer = reader.IsDBNull(reader.GetOrdinal("issuer"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("issuer")),
|
||||
SubjectAlternativeName = reader.IsDBNull(reader.GetOrdinal("subject_alternative_name"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("subject_alternative_name")),
|
||||
KeyId = reader.IsDBNull(reader.GetOrdinal("key_id"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("key_id")),
|
||||
MatchMode = Enum.Parse<WatchlistMatchMode>(reader.GetString(reader.GetOrdinal("match_mode"))),
|
||||
Severity = Enum.Parse<IdentityAlertSeverity>(reader.GetString(reader.GetOrdinal("severity"))),
|
||||
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
|
||||
ChannelOverrides = channelOverrides,
|
||||
SuppressDuplicatesMinutes = reader.GetInt32(reader.GetOrdinal("suppress_duplicates_minutes")),
|
||||
Tags = tags,
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetDateTime(reader.GetOrdinal("updated_at")),
|
||||
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
|
||||
UpdatedBy = reader.GetString(reader.GetOrdinal("updated_by"))
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record CachedEntries(IReadOnlyList<WatchedIdentity> Entries, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the alert dedup repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresAlertDedupRepository : IAlertDedupRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
|
||||
public PostgresAlertDedupRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AlertDedupStatus> CheckAndUpdateAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
int dedupWindowMinutes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var windowStart = DateTimeOffset.UtcNow.AddMinutes(-dedupWindowMinutes);
|
||||
var windowEnd = DateTimeOffset.UtcNow.AddMinutes(dedupWindowMinutes);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO attestor.identity_alert_dedup (watchlist_id, identity_hash, last_alert_at, alert_count)
|
||||
VALUES (@watchlistId, @identityHash, @now, 1)
|
||||
ON CONFLICT (watchlist_id, identity_hash) DO UPDATE SET
|
||||
alert_count = CASE
|
||||
WHEN attestor.identity_alert_dedup.last_alert_at < @windowStart
|
||||
THEN 1
|
||||
ELSE attestor.identity_alert_dedup.alert_count + 1
|
||||
END,
|
||||
last_alert_at = CASE
|
||||
WHEN attestor.identity_alert_dedup.last_alert_at < @windowStart
|
||||
THEN @now
|
||||
ELSE attestor.identity_alert_dedup.last_alert_at
|
||||
END
|
||||
RETURNING
|
||||
CASE WHEN last_alert_at < @now THEN FALSE ELSE TRUE END as should_suppress,
|
||||
alert_count,
|
||||
last_alert_at + INTERVAL '1 minute' * @dedupMinutes as window_expires
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("watchlistId", watchlistId);
|
||||
cmd.Parameters.AddWithValue("identityHash", identityHash);
|
||||
cmd.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
|
||||
cmd.Parameters.AddWithValue("windowStart", windowStart);
|
||||
cmd.Parameters.AddWithValue("dedupMinutes", dedupWindowMinutes);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var shouldSuppress = reader.GetBoolean(0);
|
||||
var alertCount = reader.GetInt32(1);
|
||||
var windowExpires = reader.GetDateTime(2);
|
||||
|
||||
return shouldSuppress
|
||||
? AlertDedupStatus.Suppress(alertCount, windowExpires)
|
||||
: AlertDedupStatus.Send(alertCount - 1);
|
||||
}
|
||||
|
||||
return AlertDedupStatus.Send();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetSuppressedCountAsync(
|
||||
Guid watchlistId,
|
||||
string identityHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT alert_count FROM attestor.identity_alert_dedup
|
||||
WHERE watchlist_id = @watchlistId AND identity_hash = @identityHash
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("watchlistId", watchlistId);
|
||||
cmd.Parameters.AddWithValue("identityHash", identityHash);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken);
|
||||
return result is null ? 0 : Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Clean up records older than 7 days
|
||||
const string sql = """
|
||||
DELETE FROM attestor.identity_alert_dedup
|
||||
WHERE last_alert_at < NOW() - INTERVAL '7 days'
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.Watchlist;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
@@ -184,6 +185,10 @@ internal static class AttestorWebServiceComposition
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy());
|
||||
|
||||
// Identity watchlist services (WATCH-006)
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddWatchlistServicesInMemory(builder.Configuration);
|
||||
|
||||
var openTelemetry = builder.Services.AddOpenTelemetry();
|
||||
|
||||
openTelemetry.WithMetrics(metricsBuilder =>
|
||||
@@ -269,6 +274,25 @@ internal static class AttestorWebServiceComposition
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.read", "attestor.verify", "attestor.write"));
|
||||
});
|
||||
|
||||
// Watchlist authorization policies (WATCH-006)
|
||||
options.AddPolicy("watchlist:read", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.read", "watchlist.write", "attestor.write"));
|
||||
});
|
||||
|
||||
options.AddPolicy("watchlist:write", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.write", "attestor.write"));
|
||||
});
|
||||
|
||||
options.AddPolicy("watchlist:admin", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.admin", "attestor.write"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,6 +406,7 @@ internal static class AttestorWebServiceComposition
|
||||
|
||||
app.MapControllers();
|
||||
app.MapAttestorEndpoints(attestorOptions);
|
||||
app.MapWatchlistEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
}
|
||||
|
||||
@@ -31,5 +31,6 @@
|
||||
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Spdx3\StellaOps.Attestor.Spdx3.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Watchlist\StellaOps.Attestor.Watchlist.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistEndpoints.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-006
|
||||
// Description: REST API endpoints for identity watchlist management.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Maps watchlist management endpoints.
|
||||
/// </summary>
|
||||
internal static class WatchlistEndpoints
|
||||
{
|
||||
public static void MapWatchlistEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/watchlist")
|
||||
.WithTags("Watchlist")
|
||||
.RequireAuthorization();
|
||||
|
||||
// List watchlist entries
|
||||
group.MapGet("", ListWatchlistEntries)
|
||||
.RequireAuthorization("watchlist:read")
|
||||
.Produces<WatchlistListResponse>(StatusCodes.Status200OK)
|
||||
.WithSummary("List watchlist entries")
|
||||
.WithDescription("Returns all watchlist entries for the tenant, optionally including global entries.");
|
||||
|
||||
// Get single entry
|
||||
group.MapGet("{id:guid}", GetWatchlistEntry)
|
||||
.RequireAuthorization("watchlist:read")
|
||||
.Produces<WatchlistEntryResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.WithSummary("Get watchlist entry")
|
||||
.WithDescription("Returns a single watchlist entry by ID.");
|
||||
|
||||
// Create entry
|
||||
group.MapPost("", CreateWatchlistEntry)
|
||||
.RequireAuthorization("watchlist:write")
|
||||
.Produces<WatchlistEntryResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.WithSummary("Create watchlist entry")
|
||||
.WithDescription("Creates a new watchlist entry for monitoring identity appearances.");
|
||||
|
||||
// Update entry
|
||||
group.MapPut("{id:guid}", UpdateWatchlistEntry)
|
||||
.RequireAuthorization("watchlist:write")
|
||||
.Produces<WatchlistEntryResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.WithSummary("Update watchlist entry")
|
||||
.WithDescription("Updates an existing watchlist entry.");
|
||||
|
||||
// Delete entry
|
||||
group.MapDelete("{id:guid}", DeleteWatchlistEntry)
|
||||
.RequireAuthorization("watchlist:write")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.WithSummary("Delete watchlist entry")
|
||||
.WithDescription("Deletes a watchlist entry.");
|
||||
|
||||
// Test pattern
|
||||
group.MapPost("{id:guid}/test", TestWatchlistPattern)
|
||||
.RequireAuthorization("watchlist:read")
|
||||
.Produces<WatchlistTestResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.WithSummary("Test watchlist pattern")
|
||||
.WithDescription("Tests if a sample identity matches the watchlist entry pattern.");
|
||||
|
||||
// List recent alerts
|
||||
group.MapGet("alerts", ListWatchlistAlerts)
|
||||
.RequireAuthorization("watchlist:read")
|
||||
.Produces<WatchlistAlertsResponse>(StatusCodes.Status200OK)
|
||||
.WithSummary("List recent alerts")
|
||||
.WithDescription("Returns recent alerts generated by watchlist matches.");
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListWatchlistEntries(
|
||||
HttpContext context,
|
||||
IWatchlistRepository repository,
|
||||
bool includeGlobal = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var entries = await repository.ListAsync(tenantId, includeGlobal, cancellationToken);
|
||||
|
||||
var response = new WatchlistListResponse
|
||||
{
|
||||
Items = entries.Select(WatchlistEntryResponse.FromDomain).ToList(),
|
||||
TotalCount = entries.Count
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWatchlistEntry(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
IWatchlistRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = await repository.GetAsync(id, cancellationToken);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId(context);
|
||||
if (!CanAccessEntry(entry, tenantId))
|
||||
{
|
||||
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
|
||||
}
|
||||
|
||||
return Results.Ok(WatchlistEntryResponse.FromDomain(entry));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateWatchlistEntry(
|
||||
WatchlistEntryRequest request,
|
||||
HttpContext context,
|
||||
IWatchlistRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var userId = GetUserId(context);
|
||||
|
||||
// Only admins can create Global/System entries
|
||||
if (request.Scope is WatchlistScope.Global or WatchlistScope.System)
|
||||
{
|
||||
if (!IsAdmin(context))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
title: "Only administrators can create global or system scope entries.");
|
||||
}
|
||||
}
|
||||
|
||||
var entry = request.ToDomain(tenantId, userId);
|
||||
var validation = entry.Validate();
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["entry"] = validation.Errors.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
var created = await repository.UpsertAsync(entry, cancellationToken);
|
||||
return Results.Created($"/api/v1/watchlist/{created.Id}", WatchlistEntryResponse.FromDomain(created));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWatchlistEntry(
|
||||
Guid id,
|
||||
WatchlistEntryRequest request,
|
||||
HttpContext context,
|
||||
IWatchlistRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
var userId = GetUserId(context);
|
||||
|
||||
var existing = await repository.GetAsync(id, cancellationToken);
|
||||
if (existing is null || !CanAccessEntry(existing, tenantId))
|
||||
{
|
||||
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
|
||||
}
|
||||
|
||||
// Can't change scope unless admin
|
||||
if (request.Scope != existing.Scope && !IsAdmin(context))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
title: "Only administrators can change entry scope.");
|
||||
}
|
||||
|
||||
var updated = request.ToDomain(tenantId, userId) with
|
||||
{
|
||||
Id = id,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var validation = updated.Validate();
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["entry"] = validation.Errors.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
var saved = await repository.UpsertAsync(updated, cancellationToken);
|
||||
return Results.Ok(WatchlistEntryResponse.FromDomain(saved));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteWatchlistEntry(
|
||||
Guid id,
|
||||
HttpContext context,
|
||||
IWatchlistRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
var existing = await repository.GetAsync(id, cancellationToken);
|
||||
if (existing is null || !CanAccessEntry(existing, tenantId))
|
||||
{
|
||||
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
|
||||
}
|
||||
|
||||
// System entries cannot be deleted
|
||||
if (existing.Scope == WatchlistScope.System)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
title: "System scope entries cannot be deleted.");
|
||||
}
|
||||
|
||||
// Global entries require admin
|
||||
if (existing.Scope == WatchlistScope.Global && !IsAdmin(context))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
title: "Only administrators can delete global scope entries.");
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(id, tenantId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> TestWatchlistPattern(
|
||||
Guid id,
|
||||
WatchlistTestRequest request,
|
||||
HttpContext context,
|
||||
IWatchlistRepository repository,
|
||||
IIdentityMatcher matcher,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
var entry = await repository.GetAsync(id, cancellationToken);
|
||||
if (entry is null || !CanAccessEntry(entry, tenantId))
|
||||
{
|
||||
return Results.NotFound(new { Message = $"Watchlist entry {id} not found" });
|
||||
}
|
||||
|
||||
var identity = new SignerIdentityInput
|
||||
{
|
||||
Issuer = request.Issuer,
|
||||
SubjectAlternativeName = request.SubjectAlternativeName,
|
||||
KeyId = request.KeyId
|
||||
};
|
||||
|
||||
var match = matcher.TestMatch(identity, entry);
|
||||
|
||||
return Results.Ok(new WatchlistTestResponse
|
||||
{
|
||||
Matches = match is not null,
|
||||
MatchedFields = match?.Fields ?? MatchedFields.None,
|
||||
MatchScore = match?.MatchScore ?? 0,
|
||||
Entry = WatchlistEntryResponse.FromDomain(entry)
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<IResult> ListWatchlistAlerts(
|
||||
HttpContext context,
|
||||
int? limit = 100,
|
||||
string? since = null,
|
||||
string? severity = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Implement alert history retrieval
|
||||
// This would query a separate alerts table or event store
|
||||
var response = new WatchlistAlertsResponse
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0
|
||||
};
|
||||
|
||||
return Task.FromResult<IResult>(Results.Ok(response));
|
||||
}
|
||||
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
return context.User.FindFirst("tenant_id")?.Value ?? "default";
|
||||
}
|
||||
|
||||
private static string GetUserId(HttpContext context)
|
||||
{
|
||||
return context.User.FindFirst("sub")?.Value ??
|
||||
context.User.FindFirst("name")?.Value ??
|
||||
"anonymous";
|
||||
}
|
||||
|
||||
private static bool IsAdmin(HttpContext context)
|
||||
{
|
||||
return context.User.IsInRole("admin") ||
|
||||
context.User.HasClaim("scope", "watchlist:admin");
|
||||
}
|
||||
|
||||
private static bool CanAccessEntry(WatchedIdentity entry, string tenantId)
|
||||
{
|
||||
return entry.TenantId == tenantId ||
|
||||
entry.Scope is WatchlistScope.Global or WatchlistScope.System;
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response Contracts
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a watchlist entry.
|
||||
/// </summary>
|
||||
public sealed record WatchlistEntryRequest
|
||||
{
|
||||
public required string DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public WatchlistMatchMode MatchMode { get; init; } = WatchlistMatchMode.Exact;
|
||||
public IdentityAlertSeverity Severity { get; init; } = IdentityAlertSeverity.Warning;
|
||||
public bool Enabled { get; init; } = true;
|
||||
public IReadOnlyList<string>? ChannelOverrides { get; init; }
|
||||
public int SuppressDuplicatesMinutes { get; init; } = 60;
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
public WatchlistScope Scope { get; init; } = WatchlistScope.Tenant;
|
||||
|
||||
public WatchedIdentity ToDomain(string tenantId, string userId) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DisplayName = DisplayName,
|
||||
Description = Description,
|
||||
Issuer = Issuer,
|
||||
SubjectAlternativeName = SubjectAlternativeName,
|
||||
KeyId = KeyId,
|
||||
MatchMode = MatchMode,
|
||||
Severity = Severity,
|
||||
Enabled = Enabled,
|
||||
ChannelOverrides = ChannelOverrides,
|
||||
SuppressDuplicatesMinutes = SuppressDuplicatesMinutes,
|
||||
Tags = Tags,
|
||||
Scope = Scope,
|
||||
CreatedBy = userId,
|
||||
UpdatedBy = userId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a single watchlist entry.
|
||||
/// </summary>
|
||||
public sealed record WatchlistEntryResponse
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public required WatchlistMatchMode MatchMode { get; init; }
|
||||
public required IdentityAlertSeverity Severity { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public IReadOnlyList<string>? ChannelOverrides { get; init; }
|
||||
public required int SuppressDuplicatesMinutes { get; init; }
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
public required WatchlistScope Scope { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public required string CreatedBy { get; init; }
|
||||
public required string UpdatedBy { get; init; }
|
||||
|
||||
public static WatchlistEntryResponse FromDomain(WatchedIdentity entry) => new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
TenantId = entry.TenantId,
|
||||
DisplayName = entry.DisplayName,
|
||||
Description = entry.Description,
|
||||
Issuer = entry.Issuer,
|
||||
SubjectAlternativeName = entry.SubjectAlternativeName,
|
||||
KeyId = entry.KeyId,
|
||||
MatchMode = entry.MatchMode,
|
||||
Severity = entry.Severity,
|
||||
Enabled = entry.Enabled,
|
||||
ChannelOverrides = entry.ChannelOverrides,
|
||||
SuppressDuplicatesMinutes = entry.SuppressDuplicatesMinutes,
|
||||
Tags = entry.Tags,
|
||||
Scope = entry.Scope,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
UpdatedAt = entry.UpdatedAt,
|
||||
CreatedBy = entry.CreatedBy,
|
||||
UpdatedBy = entry.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing watchlist entries.
|
||||
/// </summary>
|
||||
public sealed record WatchlistListResponse
|
||||
{
|
||||
public required IReadOnlyList<WatchlistEntryResponse> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test a watchlist pattern.
|
||||
/// </summary>
|
||||
public sealed record WatchlistTestRequest
|
||||
{
|
||||
public string? Issuer { get; init; }
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from testing a watchlist pattern.
|
||||
/// </summary>
|
||||
public sealed record WatchlistTestResponse
|
||||
{
|
||||
public required bool Matches { get; init; }
|
||||
public required MatchedFields MatchedFields { get; init; }
|
||||
public required int MatchScore { get; init; }
|
||||
public required WatchlistEntryResponse Entry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing watchlist alerts.
|
||||
/// </summary>
|
||||
public sealed record WatchlistAlertsResponse
|
||||
{
|
||||
public required IReadOnlyList<WatchlistAlertItem> Items { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single alert item.
|
||||
/// </summary>
|
||||
public sealed record WatchlistAlertItem
|
||||
{
|
||||
public required Guid AlertId { get; init; }
|
||||
public required Guid WatchlistEntryId { get; init; }
|
||||
public required string WatchlistEntryName { get; init; }
|
||||
public required IdentityAlertSeverity Severity { get; init; }
|
||||
public string? MatchedIssuer { get; init; }
|
||||
public string? MatchedSan { get; init; }
|
||||
public string? MatchedKeyId { get; init; }
|
||||
public string? RekorUuid { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user