Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Store for risk scoring jobs.
|
||||
/// </summary>
|
||||
public interface IRiskScoringJobStore
|
||||
{
|
||||
Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default);
|
||||
Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default);
|
||||
Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of risk scoring job store.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRiskScoringJobStore : IRiskScoringJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RiskScoringJob> _jobs = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryRiskScoringJobStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task SaveAsync(RiskScoringJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByStatusAsync(RiskScoringJobStatus status, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.Status == status)
|
||||
.OrderBy(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskScoringJob>> ListByTenantAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobs = _jobs.Values
|
||||
.Where(j => j.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(j => j.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskScoringJob>>(jobs);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(string jobId, RiskScoringJobStatus status, string? errorMessage = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_jobs.TryGetValue(jobId, out var job))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = job with
|
||||
{
|
||||
Status = status,
|
||||
StartedAt = status == RiskScoringJobStatus.Running ? now : job.StartedAt,
|
||||
CompletedAt = status is RiskScoringJobStatus.Completed or RiskScoringJobStatus.Failed or RiskScoringJobStatus.Cancelled ? now : job.CompletedAt,
|
||||
ErrorMessage = errorMessage ?? job.ErrorMessage
|
||||
};
|
||||
_jobs[jobId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskScoringJob?> DequeueNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var next = _jobs.Values
|
||||
.Where(j => j.Status == RiskScoringJobStatus.Queued)
|
||||
.OrderByDescending(j => j.Priority)
|
||||
.ThenBy(j => j.RequestedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (next != null)
|
||||
{
|
||||
var running = next with
|
||||
{
|
||||
Status = RiskScoringJobStatus.Running,
|
||||
StartedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_jobs[next.JobId] = running;
|
||||
return Task.FromResult<RiskScoringJob?>(running);
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskScoringJob?>(null);
|
||||
}
|
||||
}
|
||||
131
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs
Normal file
131
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Event indicating a finding has been created or updated.
|
||||
/// </summary>
|
||||
public sealed record FindingChangedEvent(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("change_type")] FindingChangeType ChangeType,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of finding change.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FindingChangeType>))]
|
||||
public enum FindingChangeType
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
Created,
|
||||
|
||||
[JsonPropertyName("updated")]
|
||||
Updated,
|
||||
|
||||
[JsonPropertyName("enriched")]
|
||||
Enriched,
|
||||
|
||||
[JsonPropertyName("vex_applied")]
|
||||
VexApplied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJobRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority = RiskScoringPriority.Normal,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// A finding to score.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringFinding(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("trigger")] FindingChangeType Trigger);
|
||||
|
||||
/// <summary>
|
||||
/// Priority for risk scoring jobs.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringPriority>))]
|
||||
public enum RiskScoringPriority
|
||||
{
|
||||
[JsonPropertyName("low")]
|
||||
Low,
|
||||
|
||||
[JsonPropertyName("normal")]
|
||||
Normal,
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
High,
|
||||
|
||||
[JsonPropertyName("emergency")]
|
||||
Emergency
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A queued or completed risk scoring job.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash,
|
||||
[property: JsonPropertyName("findings")] IReadOnlyList<RiskScoringFinding> Findings,
|
||||
[property: JsonPropertyName("priority")] RiskScoringPriority Priority,
|
||||
[property: JsonPropertyName("status")] RiskScoringJobStatus Status,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
|
||||
[property: JsonPropertyName("started_at")] DateTimeOffset? StartedAt = null,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("error_message")] string? ErrorMessage = null);
|
||||
|
||||
/// <summary>
|
||||
/// Status of a risk scoring job.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<RiskScoringJobStatus>))]
|
||||
public enum RiskScoringJobStatus
|
||||
{
|
||||
[JsonPropertyName("queued")]
|
||||
Queued,
|
||||
|
||||
[JsonPropertyName("running")]
|
||||
Running,
|
||||
|
||||
[JsonPropertyName("completed")]
|
||||
Completed,
|
||||
|
||||
[JsonPropertyName("failed")]
|
||||
Failed,
|
||||
|
||||
[JsonPropertyName("cancelled")]
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of scoring a single finding.
|
||||
/// </summary>
|
||||
public sealed record RiskScoringResult(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("raw_score")] double RawScore,
|
||||
[property: JsonPropertyName("normalized_score")] double NormalizedScore,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("signal_values")] IReadOnlyDictionary<string, object?> SignalValues,
|
||||
[property: JsonPropertyName("signal_contributions")] IReadOnlyDictionary<string, double> SignalContributions,
|
||||
[property: JsonPropertyName("override_applied")] string? OverrideApplied,
|
||||
[property: JsonPropertyName("override_reason")] string? OverrideReason,
|
||||
[property: JsonPropertyName("scored_at")] DateTimeOffset ScoredAt);
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Service for triggering risk scoring jobs when findings change.
|
||||
/// </summary>
|
||||
public sealed class RiskScoringTriggerService
|
||||
{
|
||||
private readonly ILogger<RiskScoringTriggerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly IRiskScoringJobStore _jobStore;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers;
|
||||
private readonly TimeSpan _deduplicationWindow;
|
||||
|
||||
public RiskScoringTriggerService(
|
||||
ILogger<RiskScoringTriggerService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
IRiskScoringJobStore jobStore)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
|
||||
_hasher = new RiskProfileHasher();
|
||||
_recentTriggers = new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
_deduplicationWindow = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a finding changed event and creates a scoring job if appropriate.
|
||||
/// </summary>
|
||||
/// <param name="evt">The finding changed event.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingChangedAsync(
|
||||
FindingChangedEvent evt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evt);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_scoring.trigger");
|
||||
activity?.SetTag("finding.id", evt.FindingId);
|
||||
activity?.SetTag("change_type", evt.ChangeType.ToString());
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping scoring for {FindingId}", evt.FindingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var triggerKey = BuildTriggerKey(evt);
|
||||
if (IsRecentlyTriggered(triggerKey))
|
||||
{
|
||||
_logger.LogDebug("Skipping duplicate trigger for {FindingId} within deduplication window", evt.FindingId);
|
||||
PolicyEngineTelemetry.RiskScoringTriggersSkipped.Add(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: evt.TenantId,
|
||||
ContextId: evt.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: new[]
|
||||
{
|
||||
new RiskScoringFinding(
|
||||
evt.FindingId,
|
||||
evt.ComponentPurl,
|
||||
evt.AdvisoryId,
|
||||
evt.ChangeType)
|
||||
},
|
||||
Priority: DeterminePriority(evt.ChangeType),
|
||||
CorrelationId: evt.CorrelationId,
|
||||
RequestedAt: evt.Timestamp);
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RecordTrigger(triggerKey);
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created risk scoring job {JobId} for finding {FindingId} (trigger: {ChangeType})",
|
||||
job.JobId, evt.FindingId, evt.ChangeType);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles multiple finding changed events in batch.
|
||||
/// </summary>
|
||||
/// <param name="events">The finding changed events.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created job, or null if all events were skipped.</returns>
|
||||
public async Task<RiskScoringJob?> HandleFindingsBatchAsync(
|
||||
IReadOnlyList<FindingChangedEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_profileService.IsEnabled)
|
||||
{
|
||||
_logger.LogDebug("Risk profile integration disabled; skipping batch scoring");
|
||||
return null;
|
||||
}
|
||||
|
||||
var uniqueEvents = events
|
||||
.Where(e => !IsRecentlyTriggered(BuildTriggerKey(e)))
|
||||
.GroupBy(e => e.FindingId)
|
||||
.Select(g => g.OrderByDescending(e => e.Timestamp).First())
|
||||
.ToList();
|
||||
|
||||
if (uniqueEvents.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All events in batch were duplicates; skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstEvent = uniqueEvents[0];
|
||||
var highestPriority = uniqueEvents.Select(e => DeterminePriority(e.ChangeType)).Max();
|
||||
|
||||
var request = new RiskScoringJobRequest(
|
||||
TenantId: firstEvent.TenantId,
|
||||
ContextId: firstEvent.ContextId,
|
||||
ProfileId: _profileService.DefaultProfileId,
|
||||
Findings: uniqueEvents.Select(e => new RiskScoringFinding(
|
||||
e.FindingId,
|
||||
e.ComponentPurl,
|
||||
e.AdvisoryId,
|
||||
e.ChangeType)).ToList(),
|
||||
Priority: highestPriority,
|
||||
CorrelationId: firstEvent.CorrelationId,
|
||||
RequestedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
var job = await CreateJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var evt in uniqueEvents)
|
||||
{
|
||||
RecordTrigger(BuildTriggerKey(evt));
|
||||
}
|
||||
|
||||
PolicyEngineTelemetry.RiskScoringJobsCreated.Add(1);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created batch risk scoring job {JobId} for {FindingCount} findings",
|
||||
job.JobId, uniqueEvents.Count);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a risk scoring job from a request.
|
||||
/// </summary>
|
||||
public async Task<RiskScoringJob> CreateJobAsync(
|
||||
RiskScoringJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var profile = _profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
|
||||
}
|
||||
|
||||
var profileHash = _hasher.ComputeHash(profile);
|
||||
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
var jobId = GenerateJobId(request.TenantId, request.ContextId, requestedAt);
|
||||
|
||||
var job = new RiskScoringJob(
|
||||
JobId: jobId,
|
||||
TenantId: request.TenantId,
|
||||
ContextId: request.ContextId,
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileHash: profileHash,
|
||||
Findings: request.Findings,
|
||||
Priority: request.Priority,
|
||||
Status: RiskScoringJobStatus.Queued,
|
||||
RequestedAt: requestedAt,
|
||||
CorrelationId: request.CorrelationId);
|
||||
|
||||
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current queue depth.
|
||||
/// </summary>
|
||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queued = await _jobStore.ListByStatusAsync(RiskScoringJobStatus.Queued, limit: 10000, cancellationToken).ConfigureAwait(false);
|
||||
return queued.Count;
|
||||
}
|
||||
|
||||
private static RiskScoringPriority DeterminePriority(FindingChangeType changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
FindingChangeType.Created => RiskScoringPriority.High,
|
||||
FindingChangeType.Enriched => RiskScoringPriority.High,
|
||||
FindingChangeType.VexApplied => RiskScoringPriority.High,
|
||||
FindingChangeType.Updated => RiskScoringPriority.Normal,
|
||||
_ => RiskScoringPriority.Normal
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTriggerKey(FindingChangedEvent evt)
|
||||
{
|
||||
return $"{evt.TenantId}|{evt.ContextId}|{evt.FindingId}|{evt.ChangeType}";
|
||||
}
|
||||
|
||||
private bool IsRecentlyTriggered(string key)
|
||||
{
|
||||
if (_recentTriggers.TryGetValue(key, out var timestamp))
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - timestamp;
|
||||
return elapsed < _deduplicationWindow;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RecordTrigger(string key)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_recentTriggers[key] = now;
|
||||
|
||||
CleanupOldTriggers(now);
|
||||
}
|
||||
|
||||
private void CleanupOldTriggers(DateTimeOffset now)
|
||||
{
|
||||
var threshold = now - _deduplicationWindow * 2;
|
||||
var keysToRemove = _recentTriggers
|
||||
.Where(kvp => kvp.Value < threshold)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_recentTriggers.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateJobId(string tenantId, string contextId, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{contextId}|{timestamp:O}|{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"rsj-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user