using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Infrastructure; using StellaOps.Findings.Ledger.Options; using StellaOps.Findings.Ledger.Workflow; namespace StellaOps.Findings.Ledger.Services; public interface IFindingWorkflowService { Task AssignAsync(AssignWorkflowRequest request, CancellationToken cancellationToken); Task CommentAsync(CommentWorkflowRequest request, CancellationToken cancellationToken); Task AcceptRiskAsync(AcceptRiskWorkflowRequest request, CancellationToken cancellationToken); Task TargetFixAsync(TargetFixWorkflowRequest request, CancellationToken cancellationToken); Task VerifyFixAsync(VerifyFixWorkflowRequest request, CancellationToken cancellationToken); Task ReopenAsync(ReopenWorkflowRequest request, CancellationToken cancellationToken); } public sealed class FindingWorkflowService : IFindingWorkflowService { private readonly ILedgerEventRepository repository; private readonly ILedgerEventWriteService writeService; private readonly IAttachmentEncryptionService attachmentEncryptionService; private readonly IAttachmentUrlSigner attachmentUrlSigner; private readonly LedgerServiceOptions.AttachmentsOptions attachmentOptions; private readonly TimeProvider timeProvider; private readonly ILogger logger; public FindingWorkflowService( ILedgerEventRepository repository, ILedgerEventWriteService writeService, IAttachmentEncryptionService attachmentEncryptionService, IAttachmentUrlSigner attachmentUrlSigner, IOptions options, TimeProvider timeProvider, ILogger logger) { this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); this.writeService = writeService ?? throw new ArgumentNullException(nameof(writeService)); this.attachmentEncryptionService = attachmentEncryptionService ?? throw new ArgumentNullException(nameof(attachmentEncryptionService)); this.attachmentUrlSigner = attachmentUrlSigner ?? throw new ArgumentNullException(nameof(attachmentUrlSigner)); attachmentOptions = options?.Value?.Attachments ?? throw new ArgumentNullException(nameof(options)); this.timeProvider = timeProvider ?? TimeProvider.System; this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public Task AssignAsync(AssignWorkflowRequest request, CancellationToken cancellationToken) { var errors = ValidateBase(request); if (request.Assignee is null || string.IsNullOrWhiteSpace(request.Assignee.Id)) { errors.Add("assignee_required"); } if (errors.Count > 0) { return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); } var payload = CreateBasePayload(request); payload["action"] = "assign"; payload["assignee"] = BuildAssigneeNode(request.Assignee); AddComment(payload, request.Comment); ApplyStatus(payload, request.Status); ApplyAttachments(payload, request.Attachments); return AppendAsync(request, LedgerEventConstants.EventFindingAssignmentChanged, payload, cancellationToken); } public Task CommentAsync(CommentWorkflowRequest request, CancellationToken cancellationToken) { var errors = ValidateBase(request); if (string.IsNullOrWhiteSpace(request.Comment)) { errors.Add("comment_required"); } if (errors.Count > 0) { return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); } var payload = CreateBasePayload(request); payload["action"] = "comment"; payload["comment"] = request.Comment.Trim(); ApplyAttachments(payload, request.Attachments); return AppendAsync(request, LedgerEventConstants.EventFindingCommentAdded, payload, cancellationToken); } public Task AcceptRiskAsync(AcceptRiskWorkflowRequest request, CancellationToken cancellationToken) { var errors = ValidateBase(request); if (string.IsNullOrWhiteSpace(request.Justification)) { errors.Add("justification_required"); } if (errors.Count > 0) { return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); } var payload = CreateBasePayload(request); payload["action"] = "accept-risk"; payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "accepted_risk" : request.Status; payload["justification"] = request.Justification.Trim(); if (!string.IsNullOrWhiteSpace(request.RiskOwner)) { payload["riskOwner"] = request.RiskOwner.Trim(); } if (request.ExpiresAt is { } expiresAt) { payload["expiresAt"] = FormatTimestamp(expiresAt); } ApplyAttachments(payload, request.Attachments); return AppendAsync(request, LedgerEventConstants.EventFindingAcceptedRisk, payload, cancellationToken); } public Task TargetFixAsync(TargetFixWorkflowRequest request, CancellationToken cancellationToken) { var errors = ValidateBase(request); if (string.IsNullOrWhiteSpace(request.Summary)) { errors.Add("summary_required"); } if (errors.Count > 0) { return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); } var payload = CreateBasePayload(request); payload["action"] = "target-fix"; payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "in_progress" : request.Status; payload["remediationPlan"] = request.Summary.Trim(); if (request.TargetCompletion is { } targetCompletion) { payload["targetCompletion"] = FormatTimestamp(targetCompletion); } if (!string.IsNullOrWhiteSpace(request.TicketId) || request.TicketUrl is not null) { var ticket = new JsonObject(); if (!string.IsNullOrWhiteSpace(request.TicketId)) { ticket["id"] = request.TicketId.Trim(); } if (request.TicketUrl is not null) { ticket["url"] = request.TicketUrl.ToString(); } payload["ticket"] = ticket; } ApplyAttachments(payload, request.Attachments); return AppendAsync(request, LedgerEventConstants.EventFindingRemediationPlanAdded, payload, cancellationToken); } public Task VerifyFixAsync(VerifyFixWorkflowRequest request, CancellationToken cancellationToken) { var errors = ValidateBase(request); if (errors.Count > 0) { return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); } var payload = CreateBasePayload(request); payload["action"] = "verify-fix"; payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "verified" : request.Status; if (!string.IsNullOrWhiteSpace(request.Evidence)) { payload["evidence"] = request.Evidence.Trim(); } if (!string.IsNullOrWhiteSpace(request.Notes)) { payload["notes"] = request.Notes.Trim(); } ApplyAttachments(payload, request.Attachments); return AppendAsync(request, LedgerEventConstants.EventFindingStatusChanged, payload, cancellationToken); } public Task ReopenAsync(ReopenWorkflowRequest request, CancellationToken cancellationToken) { var errors = ValidateBase(request); if (errors.Count > 0) { return Task.FromResult(LedgerWriteResult.ValidationFailed([.. errors])); } var payload = CreateBasePayload(request); payload["action"] = "reopen"; payload["status"] = string.IsNullOrWhiteSpace(request.Status) ? "affected" : request.Status; if (!string.IsNullOrWhiteSpace(request.Reason)) { payload["reason"] = request.Reason.Trim(); } ApplyAttachments(payload, request.Attachments); return AppendAsync(request, LedgerEventConstants.EventFindingStatusChanged, payload, cancellationToken); } private async Task AppendAsync( WorkflowMutationRequest request, string eventType, JsonObject payload, CancellationToken cancellationToken) { var chainContext = await ResolveChainContextAsync(request, cancellationToken).ConfigureAwait(false); var eventId = request.EventId ?? Guid.CreateVersion7(); var occurredAt = (request.OccurredAt ?? timeProvider.GetUtcNow()).ToUniversalTime(); var recordedAt = timeProvider.GetUtcNow().ToUniversalTime(); var envelope = BuildCanonicalEnvelope(request, eventId, eventType, chainContext, occurredAt, recordedAt, payload); var draft = new LedgerEventDraft( request.TenantId.Trim(), chainContext.ChainId, chainContext.Sequence, eventId, eventType, request.PolicyVersion.Trim(), request.FindingId.Trim(), request.ArtifactId.Trim(), SourceRunId: null, request.Actor.Id.Trim(), request.Actor.Type.Trim(), occurredAt, recordedAt, payload, envelope, chainContext.PreviousHash); var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); if (result.Status == LedgerWriteStatus.Conflict) { logger.LogWarning( "Workflow append conflict for tenant {Tenant} finding {Finding} ({EventType}).", request.TenantId, request.FindingId, eventType); } return result; } private async Task ResolveChainContextAsync(WorkflowMutationRequest request, CancellationToken cancellationToken) { var chainId = request.ChainId ?? LedgerChainIdGenerator.FromTenantPolicy(request.TenantId, request.PolicyVersion); var head = await repository.GetChainHeadAsync(request.TenantId, chainId, cancellationToken).ConfigureAwait(false); var sequence = head is null ? 1 : head.SequenceNumber + 1; var previousHash = head?.EventHash ?? LedgerEventConstants.EmptyHash; return new ChainContext(chainId, sequence, previousHash); } private static JsonObject BuildCanonicalEnvelope( WorkflowMutationRequest request, Guid eventId, string eventType, ChainContext chainContext, DateTimeOffset occurredAt, DateTimeOffset recordedAt, JsonObject payload) { var eventObject = new JsonObject { ["id"] = eventId, ["type"] = eventType, ["tenant"] = request.TenantId.Trim(), ["chainId"] = chainContext.ChainId, ["sequence"] = chainContext.Sequence, ["policyVersion"] = request.PolicyVersion.Trim(), ["finding"] = new JsonObject { ["id"] = request.FindingId.Trim(), ["artifactId"] = request.ArtifactId.Trim(), ["vulnId"] = request.VulnerabilityId.Trim() }, ["actor"] = BuildActorNode(request.Actor), ["occurredAt"] = FormatTimestamp(occurredAt), ["recordedAt"] = FormatTimestamp(recordedAt), ["payload"] = payload }; return new JsonObject { ["event"] = eventObject }; } private static JsonObject CreateBasePayload(WorkflowMutationRequest request) { var payload = new JsonObject(); if (!string.IsNullOrWhiteSpace(request.PreviousStatus)) { payload["previousStatus"] = request.PreviousStatus.Trim(); } return payload; } private static JsonObject BuildActorNode(WorkflowActor actor) { var node = new JsonObject { ["id"] = actor.Id.Trim(), ["type"] = actor.Type.Trim() }; if (!string.IsNullOrWhiteSpace(actor.DisplayName)) { node["displayName"] = actor.DisplayName.Trim(); } return node; } private static JsonObject BuildAssigneeNode(WorkflowAssignee assignee) { var node = new JsonObject { ["id"] = assignee.Id.Trim(), ["type"] = string.IsNullOrWhiteSpace(assignee.Type) ? "user" : assignee.Type.Trim() }; if (!string.IsNullOrWhiteSpace(assignee.DisplayName)) { node["displayName"] = assignee.DisplayName.Trim(); } return node; } private static void AddComment(JsonObject payload, string? comment) { if (!string.IsNullOrWhiteSpace(comment)) { payload["comment"] = comment.Trim(); } } private static void ApplyStatus(JsonObject payload, string? status) { if (!string.IsNullOrWhiteSpace(status)) { payload["status"] = status.Trim(); } } private void ApplyAttachments(JsonObject payload, IReadOnlyList? attachments) { if (!attachmentOptions.Enabled) { return; } var nodes = BuildAttachments(attachments); if (nodes is not null) { payload["attachments"] = nodes; } } private JsonArray? BuildAttachments(IReadOnlyList? attachments) { if (attachments is null || attachments.Count == 0) { return null; } var now = timeProvider.GetUtcNow(); var normalized = attachments .Where(static attachment => attachment is not null) .Select(NormalizeAttachment) .OrderBy(attachment => attachment.AttachmentId, StringComparer.Ordinal) .ToArray(); if (normalized.Length == 0) { return null; } var array = new JsonArray(); foreach (var attachment in normalized) { var envelope = attachmentEncryptionService.CreateEnvelope(attachment, now); var signed = attachmentUrlSigner.Sign(attachment.AttachmentId, now, attachmentOptions.SignedUrlLifetime); var node = BuildAttachmentNode(attachment, envelope, signed); array.Add(node); } return array; } private static JsonObject BuildAttachmentNode( NormalizedAttachment attachment, AttachmentEncryptionResult envelope, AttachmentSignedUrl signedUrl) { var node = new JsonObject { ["id"] = attachment.AttachmentId, ["fileName"] = attachment.FileName, ["contentType"] = attachment.ContentType, ["size"] = attachment.SizeBytes, ["sha256"] = attachment.Sha256, ["signedUrl"] = signedUrl.Url.ToString(), ["urlExpiresAt"] = FormatTimestamp(signedUrl.ExpiresAt), ["envelope"] = new JsonObject { ["algorithm"] = envelope.Algorithm, ["ciphertext"] = envelope.Ciphertext, ["nonce"] = envelope.Nonce, ["tag"] = envelope.Tag, ["expiresAt"] = FormatTimestamp(envelope.ExpiresAt) } }; if (attachment.Metadata is { Count: > 0 }) { var metadata = new JsonObject(); foreach (var kvp in attachment.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)) { metadata[kvp.Key] = kvp.Value; } node["metadata"] = metadata; } return node; } private static NormalizedAttachment NormalizeAttachment(WorkflowAttachmentMetadata attachment) { ArgumentNullException.ThrowIfNull(attachment); var metadata = attachment.Metadata is null ? null : new Dictionary( attachment.Metadata .Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null) .ToDictionary( kv => kv.Key.Trim(), kv => kv.Value!.Trim(), StringComparer.Ordinal), StringComparer.Ordinal); return new NormalizedAttachment( attachment.AttachmentId.Trim(), attachment.FileName.Trim(), attachment.ContentType.Trim(), attachment.SizeBytes, attachment.Sha256.Trim(), metadata); } private static void ApplyAttachmentsValidation(IReadOnlyList? attachments, ICollection errors) { if (attachments is null || attachments.Count == 0) { return; } foreach (var attachment in attachments) { ValidateAttachment(attachment, errors); } } private static List ValidateBase(WorkflowMutationRequest request) { var errors = new List(); if (request is null) { errors.Add("request_required"); return errors; } if (string.IsNullOrWhiteSpace(request.TenantId)) { errors.Add("tenant_id_required"); } if (string.IsNullOrWhiteSpace(request.PolicyVersion)) { errors.Add("policy_version_required"); } if (string.IsNullOrWhiteSpace(request.FindingId)) { errors.Add("finding_id_required"); } if (string.IsNullOrWhiteSpace(request.ArtifactId)) { errors.Add("artifact_id_required"); } if (string.IsNullOrWhiteSpace(request.VulnerabilityId)) { errors.Add("vuln_id_required"); } if (request.Actor is null || string.IsNullOrWhiteSpace(request.Actor.Id)) { errors.Add("actor_id_required"); } else if (!LedgerEventConstants.SupportedActorTypes.Contains(request.Actor.Type)) { errors.Add($"actor_type_invalid:{request.Actor.Type}"); } ApplyAttachmentsValidation(request.Attachments, errors); return errors; } private static void ValidateAttachment(WorkflowAttachmentMetadata attachment, ICollection errors) { if (attachment is null) { errors.Add("attachment_invalid"); return; } if (string.IsNullOrWhiteSpace(attachment.AttachmentId)) { errors.Add("attachment_id_required"); } if (string.IsNullOrWhiteSpace(attachment.FileName)) { errors.Add("attachment_name_required"); } if (string.IsNullOrWhiteSpace(attachment.ContentType)) { errors.Add("attachment_content_type_required"); } if (attachment.SizeBytes <= 0) { errors.Add("attachment_size_invalid"); } if (string.IsNullOrWhiteSpace(attachment.Sha256) || attachment.Sha256.Length != 64) { errors.Add("attachment_sha256_invalid"); } } private static string FormatTimestamp(DateTimeOffset value) => value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); private readonly record struct ChainContext(Guid ChainId, long Sequence, string PreviousHash); }