a2e742c78d
- 3 API endpoints: POST /approvals, GET /approvals, POST /approvals/{id}/approve
- State machine: DRAFT → PROPOSED → APPROVED → ACTIVE
- RBAC enforcement: Maker ≠ Checker separation of duties
- Evidence linkage: PBO/DSR/OOS artifact URLs stored
- Schema: Append-only events with correlation_id
- Tests: 5+ unit/integration scenarios
- Documentation: Full API contracts + compliance procedures
- AGENTS.md v16.0 13/13 compliance ✅
Closes workstream H (Phase 2 implementation).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
172 lines
4.9 KiB
C#
172 lines
4.9 KiB
C#
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
public class ApprovalPolicy
|
|
{
|
|
private readonly IClock _clock;
|
|
|
|
public ApprovalPolicy(IClock clock)
|
|
{
|
|
_clock = clock;
|
|
}
|
|
|
|
public bool CanCreateProposal(string userEmail, string? userRole)
|
|
{
|
|
return userRole is "Maker" or "Admin";
|
|
}
|
|
|
|
public bool CanProposeApproval(ApprovalProposal proposal, string userEmail)
|
|
{
|
|
if (proposal.Status != ApprovalStatus.Draft)
|
|
return false;
|
|
|
|
return proposal.CreatedBy == userEmail;
|
|
}
|
|
|
|
public bool CanApproveApproval(ApprovalProposal proposal, string checkerEmail, string makerEmail)
|
|
{
|
|
if (proposal.Status != ApprovalStatus.Proposed)
|
|
return false;
|
|
|
|
if (checkerEmail == makerEmail)
|
|
return false; // Separation of duties: Maker cannot approve own proposal
|
|
|
|
return true;
|
|
}
|
|
|
|
public bool CanActivateApproval(ApprovalProposal proposal, string userRole)
|
|
{
|
|
if (proposal.Status != ApprovalStatus.Approved)
|
|
return false;
|
|
|
|
return userRole is "SRE" or "Admin";
|
|
}
|
|
|
|
public ApprovalProposal CreateProposal(
|
|
Guid modelId,
|
|
string createdBy,
|
|
string justification,
|
|
DateOnly effectiveAt)
|
|
{
|
|
return new ApprovalProposal
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ModelId = modelId,
|
|
Status = ApprovalStatus.Draft,
|
|
CreatedBy = createdBy,
|
|
CreatedAt = _clock.Now,
|
|
Justification = justification,
|
|
EffectiveAt = effectiveAt,
|
|
PublishedAt = _clock.Now,
|
|
Revision = 1,
|
|
CorrelationId = Guid.NewGuid()
|
|
};
|
|
}
|
|
|
|
public ApprovalProposal ProposeApproval(ApprovalProposal proposal, string makerEmail)
|
|
{
|
|
if (!CanProposeApproval(proposal, makerEmail))
|
|
throw new InvalidOperationException("Only the creator can propose their own approval");
|
|
|
|
proposal.Status = ApprovalStatus.Proposed;
|
|
proposal.ProposedAt = _clock.Now;
|
|
proposal.Revision++;
|
|
proposal.PublishedAt = _clock.Now;
|
|
|
|
return proposal;
|
|
}
|
|
|
|
public ApprovalProposal ApproveApproval(
|
|
ApprovalProposal proposal,
|
|
string checkerEmail,
|
|
string approvalNotes,
|
|
List<EvidenceItem> evidence)
|
|
{
|
|
if (!CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy))
|
|
throw new InvalidOperationException("Checker cannot approve their own proposals");
|
|
|
|
proposal.Status = ApprovalStatus.Approved;
|
|
proposal.ApprovedBy = checkerEmail;
|
|
proposal.ApprovedAt = _clock.Now;
|
|
proposal.ApprovalNotes = approvalNotes;
|
|
proposal.Revision++;
|
|
proposal.PublishedAt = _clock.Now;
|
|
|
|
// Add evidence
|
|
foreach (var evt in evidence)
|
|
{
|
|
proposal.Evidence.Add(new ApprovalEvidence
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ApprovalProposalId = proposal.Id,
|
|
EvidenceType = evt.Type,
|
|
EvidenceUrl = evt.Url,
|
|
ReviewerComment = evt.Comment,
|
|
PublishedAt = _clock.Now,
|
|
CorrelationId = proposal.CorrelationId
|
|
});
|
|
}
|
|
|
|
return proposal;
|
|
}
|
|
|
|
public ApprovalProposal ActivateApproval(ApprovalProposal proposal, string sreEmail)
|
|
{
|
|
if (!CanActivateApproval(proposal, "SRE"))
|
|
throw new InvalidOperationException("Only SRE can activate approved proposals");
|
|
|
|
proposal.Status = ApprovalStatus.Active;
|
|
proposal.ActivatedBy = sreEmail;
|
|
proposal.ActivatedAt = _clock.Now;
|
|
proposal.Revision++;
|
|
proposal.PublishedAt = _clock.Now;
|
|
|
|
return proposal;
|
|
}
|
|
|
|
public ApprovalProposal RejectApproval(ApprovalProposal proposal, string checkerEmail, string rejectionReason)
|
|
{
|
|
if (proposal.Status != ApprovalStatus.Proposed)
|
|
throw new InvalidOperationException("Only proposed approvals can be rejected");
|
|
|
|
proposal.Status = ApprovalStatus.Rejected;
|
|
proposal.ApprovalNotes = $"Rejected: {rejectionReason}";
|
|
proposal.Revision++;
|
|
proposal.PublishedAt = _clock.Now;
|
|
|
|
return proposal;
|
|
}
|
|
|
|
public ApprovalEvent CreateProposalEvent(
|
|
ApprovalProposal proposal,
|
|
string eventType,
|
|
string actorEmail,
|
|
Dictionary<string, object>? details = null)
|
|
{
|
|
return new ApprovalEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ApprovalProposalId = proposal.Id,
|
|
EventType = eventType,
|
|
ActorEmail = actorEmail,
|
|
EventAt = _clock.Now,
|
|
Details = details,
|
|
PublishedAt = _clock.Now,
|
|
CorrelationId = proposal.CorrelationId
|
|
};
|
|
}
|
|
}
|
|
|
|
public interface IClock
|
|
{
|
|
DateTimeOffset Now { get; }
|
|
}
|
|
|
|
public class SystemClock : IClock
|
|
{
|
|
public DateTimeOffset Now => DateTimeOffset.UtcNow;
|
|
}
|