old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyAuditEvents.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-008
|
||||
// Description: Audit event definitions for dual-control ceremonies.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// Audit event types for ceremonies.
|
||||
/// </summary>
|
||||
public static class CeremonyAuditEvents
|
||||
{
|
||||
/// <summary>
|
||||
/// Ceremony was created.
|
||||
/// </summary>
|
||||
public const string Initiated = "signer.ceremony.initiated";
|
||||
|
||||
/// <summary>
|
||||
/// Approval was submitted.
|
||||
/// </summary>
|
||||
public const string Approved = "signer.ceremony.approved";
|
||||
|
||||
/// <summary>
|
||||
/// Threshold was reached.
|
||||
/// </summary>
|
||||
public const string ThresholdReached = "signer.ceremony.threshold_reached";
|
||||
|
||||
/// <summary>
|
||||
/// Operation was executed.
|
||||
/// </summary>
|
||||
public const string Executed = "signer.ceremony.executed";
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony expired.
|
||||
/// </summary>
|
||||
public const string Expired = "signer.ceremony.expired";
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony was cancelled.
|
||||
/// </summary>
|
||||
public const string Cancelled = "signer.ceremony.cancelled";
|
||||
|
||||
/// <summary>
|
||||
/// Approval was rejected (invalid signature, unauthorized, etc.).
|
||||
/// </summary>
|
||||
public const string ApprovalRejected = "signer.ceremony.approval_rejected";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base audit event for ceremonies.
|
||||
/// </summary>
|
||||
public abstract record CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event type.
|
||||
/// </summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony ID.
|
||||
/// </summary>
|
||||
public required Guid CeremonyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operation type.
|
||||
/// </summary>
|
||||
public required CeremonyOperationType OperationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor identity.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request trace ID.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for ceremony initiation.
|
||||
/// </summary>
|
||||
public sealed record CeremonyInitiatedEvent : CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Threshold required.
|
||||
/// </summary>
|
||||
public required int ThresholdRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operation description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for ceremony approval.
|
||||
/// </summary>
|
||||
public sealed record CeremonyApprovedEvent : CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Approver identity.
|
||||
/// </summary>
|
||||
public required string Approver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current approval count.
|
||||
/// </summary>
|
||||
public required int ApprovalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required threshold.
|
||||
/// </summary>
|
||||
public required int ThresholdRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approval reason.
|
||||
/// </summary>
|
||||
public string? ApprovalReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether threshold was reached with this approval.
|
||||
/// </summary>
|
||||
public required bool ThresholdReached { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for ceremony execution.
|
||||
/// </summary>
|
||||
public sealed record CeremonyExecutedEvent : CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Executor identity.
|
||||
/// </summary>
|
||||
public required string Executor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total approvals.
|
||||
/// </summary>
|
||||
public required int TotalApprovals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution result.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result payload (key ID, etc.).
|
||||
/// </summary>
|
||||
public string? ResultPayload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for ceremony expiration.
|
||||
/// </summary>
|
||||
public sealed record CeremonyExpiredEvent : CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Approvals received before expiration.
|
||||
/// </summary>
|
||||
public required int ApprovalsReceived { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold that was required.
|
||||
/// </summary>
|
||||
public required int ThresholdRequired { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for ceremony cancellation.
|
||||
/// </summary>
|
||||
public sealed record CeremonyCancelledEvent : CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Cancellation reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// State at time of cancellation.
|
||||
/// </summary>
|
||||
public required CeremonyState StateAtCancellation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approvals received before cancellation.
|
||||
/// </summary>
|
||||
public required int ApprovalsReceived { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for rejected approval.
|
||||
/// </summary>
|
||||
public sealed record CeremonyApprovalRejectedEvent : CeremonyAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempted approver.
|
||||
/// </summary>
|
||||
public required string AttemptedApprover { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rejection reason.
|
||||
/// </summary>
|
||||
public required string RejectionReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code.
|
||||
/// </summary>
|
||||
public required CeremonyErrorCode ErrorCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyModels.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-001, DUAL-003, DUAL-004
|
||||
// Description: Models for M-of-N dual-control signing ceremonies.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// State of a signing ceremony.
|
||||
/// </summary>
|
||||
public enum CeremonyState
|
||||
{
|
||||
/// <summary>
|
||||
/// Ceremony created, awaiting approvals.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Some approvals received, but threshold not yet reached.
|
||||
/// </summary>
|
||||
PartiallyApproved,
|
||||
|
||||
/// <summary>
|
||||
/// Threshold reached, operation approved for execution.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Operation executed successfully.
|
||||
/// </summary>
|
||||
Executed,
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony expired before threshold was reached.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony cancelled by initiator or admin.
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of key operation requiring ceremony approval.
|
||||
/// </summary>
|
||||
public enum CeremonyOperationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a new signing key.
|
||||
/// </summary>
|
||||
KeyGeneration,
|
||||
|
||||
/// <summary>
|
||||
/// Rotate an existing key.
|
||||
/// </summary>
|
||||
KeyRotation,
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a key.
|
||||
/// </summary>
|
||||
KeyRevocation,
|
||||
|
||||
/// <summary>
|
||||
/// Export a key (for escrow or backup).
|
||||
/// </summary>
|
||||
KeyExport,
|
||||
|
||||
/// <summary>
|
||||
/// Import a key from escrow or backup.
|
||||
/// </summary>
|
||||
KeyImport,
|
||||
|
||||
/// <summary>
|
||||
/// Emergency key recovery.
|
||||
/// </summary>
|
||||
KeyRecovery
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signing ceremony requiring M-of-N approvals.
|
||||
/// </summary>
|
||||
public sealed record Ceremony
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique ceremony identifier.
|
||||
/// </summary>
|
||||
public required Guid CeremonyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of operation being approved.
|
||||
/// </summary>
|
||||
public required CeremonyOperationType OperationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operation-specific payload (key ID, parameters, etc.).
|
||||
/// </summary>
|
||||
public required CeremonyOperationPayload Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of approvals required (M in M-of-N).
|
||||
/// </summary>
|
||||
public required int ThresholdRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current number of approvals received.
|
||||
/// </summary>
|
||||
public required int ThresholdReached { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current ceremony state.
|
||||
/// </summary>
|
||||
public required CeremonyState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the ceremony initiator.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the ceremony was initiated (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset InitiatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the ceremony expires (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the operation was executed (UTC), if executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Collected approvals.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CeremonyApproval> Approvals { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the ceremony.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID if multi-tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operation-specific payload for a ceremony.
|
||||
/// </summary>
|
||||
public sealed record CeremonyOperationPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier (for rotation, revocation, export).
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key algorithm (for generation).
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key size in bits (for generation).
|
||||
/// </summary>
|
||||
public int? KeySize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key usage constraints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? KeyUsages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the operation.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An approval for a ceremony.
|
||||
/// </summary>
|
||||
public sealed record CeremonyApproval
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique approval identifier.
|
||||
/// </summary>
|
||||
public required Guid ApprovalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony being approved.
|
||||
/// </summary>
|
||||
public required Guid CeremonyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the approver.
|
||||
/// </summary>
|
||||
public required string ApproverIdentity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval was given (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic signature over the ceremony details.
|
||||
/// </summary>
|
||||
public required byte[] ApprovalSignature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason or comment for approval.
|
||||
/// </summary>
|
||||
public string? ApprovalReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing the approval.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm used.
|
||||
/// </summary>
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new ceremony.
|
||||
/// </summary>
|
||||
public sealed record CreateCeremonyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of operation.
|
||||
/// </summary>
|
||||
public required CeremonyOperationType OperationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operation payload.
|
||||
/// </summary>
|
||||
public required CeremonyOperationPayload Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Override threshold (uses config default if null).
|
||||
/// </summary>
|
||||
public int? ThresholdOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Override expiration minutes (uses config default if null).
|
||||
/// </summary>
|
||||
public int? ExpirationMinutesOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to approve a ceremony.
|
||||
/// </summary>
|
||||
public sealed record ApproveCeremonyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Ceremony to approve.
|
||||
/// </summary>
|
||||
public required Guid CeremonyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic signature over ceremony details.
|
||||
/// </summary>
|
||||
public required byte[] ApprovalSignature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for approval.
|
||||
/// </summary>
|
||||
public string? ApprovalReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm.
|
||||
/// </summary>
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a ceremony operation.
|
||||
/// </summary>
|
||||
public sealed record CeremonyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated ceremony state.
|
||||
/// </summary>
|
||||
public Ceremony? Ceremony { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if failed.
|
||||
/// </summary>
|
||||
public CeremonyErrorCode? ErrorCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony error codes.
|
||||
/// </summary>
|
||||
public enum CeremonyErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Ceremony not found.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony has expired.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony already executed.
|
||||
/// </summary>
|
||||
AlreadyExecuted,
|
||||
|
||||
/// <summary>
|
||||
/// Ceremony was cancelled.
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// Approver has already approved this ceremony.
|
||||
/// </summary>
|
||||
DuplicateApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Approver is not authorized for this operation.
|
||||
/// </summary>
|
||||
UnauthorizedApprover,
|
||||
|
||||
/// <summary>
|
||||
/// Invalid approval signature.
|
||||
/// </summary>
|
||||
InvalidSignature,
|
||||
|
||||
/// <summary>
|
||||
/// Threshold configuration error.
|
||||
/// </summary>
|
||||
InvalidThreshold,
|
||||
|
||||
/// <summary>
|
||||
/// Internal error.
|
||||
/// </summary>
|
||||
InternalError
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyOptions.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-001
|
||||
// Description: Configuration options for dual-control ceremonies.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for dual-control signing ceremonies.
|
||||
/// </summary>
|
||||
public sealed class CeremonyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Signer:Ceremonies";
|
||||
|
||||
/// <summary>
|
||||
/// Whether ceremony support is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default approval threshold (M in M-of-N).
|
||||
/// </summary>
|
||||
[Range(1, 10)]
|
||||
public int DefaultThreshold { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Default ceremony expiration in minutes.
|
||||
/// </summary>
|
||||
[Range(5, 1440)]
|
||||
public int ExpirationMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Per-operation configuration.
|
||||
/// </summary>
|
||||
public Dictionary<string, OperationCeremonyConfig> Operations { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Notification configuration.
|
||||
/// </summary>
|
||||
public CeremonyNotificationConfig Notifications { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the threshold for a specific operation type.
|
||||
/// </summary>
|
||||
public int GetThreshold(CeremonyOperationType operationType)
|
||||
{
|
||||
var key = operationType.ToString().ToLowerInvariant();
|
||||
if (Operations.TryGetValue(key, out var config) && config.Threshold.HasValue)
|
||||
{
|
||||
return config.Threshold.Value;
|
||||
}
|
||||
return DefaultThreshold;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expiration minutes for a specific operation type.
|
||||
/// </summary>
|
||||
public int GetExpirationMinutes(CeremonyOperationType operationType)
|
||||
{
|
||||
var key = operationType.ToString().ToLowerInvariant();
|
||||
if (Operations.TryGetValue(key, out var config) && config.ExpirationMinutes.HasValue)
|
||||
{
|
||||
return config.ExpirationMinutes.Value;
|
||||
}
|
||||
return ExpirationMinutes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the required roles for a specific operation type.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetRequiredRoles(CeremonyOperationType operationType)
|
||||
{
|
||||
var key = operationType.ToString().ToLowerInvariant();
|
||||
if (Operations.TryGetValue(key, out var config) && config.RequiredRoles is { Count: > 0 })
|
||||
{
|
||||
return config.RequiredRoles;
|
||||
}
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-operation ceremony configuration.
|
||||
/// </summary>
|
||||
public sealed class OperationCeremonyConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Approval threshold override.
|
||||
/// </summary>
|
||||
[Range(1, 10)]
|
||||
public int? Threshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration minutes override.
|
||||
/// </summary>
|
||||
[Range(5, 1440)]
|
||||
public int? ExpirationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Roles required to approve this operation.
|
||||
/// </summary>
|
||||
public List<string> RequiredRoles { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this operation requires a ceremony (false to bypass).
|
||||
/// </summary>
|
||||
public bool RequiresCeremony { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification configuration for ceremonies.
|
||||
/// </summary>
|
||||
public sealed class CeremonyNotificationConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Notification channels to use.
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = ["email"];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify on ceremony creation.
|
||||
/// </summary>
|
||||
public bool NotifyOnCreate { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify on each approval.
|
||||
/// </summary>
|
||||
public bool NotifyOnApproval { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify on threshold reached.
|
||||
/// </summary>
|
||||
public bool NotifyOnThresholdReached { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify on execution.
|
||||
/// </summary>
|
||||
public bool NotifyOnExecution { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify on expiration warning.
|
||||
/// </summary>
|
||||
public bool NotifyOnExpirationWarning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes before expiration to send warning.
|
||||
/// </summary>
|
||||
[Range(5, 60)]
|
||||
public int ExpirationWarningMinutes { get; set; } = 15;
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyOrchestrator.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-005, DUAL-006, DUAL-007
|
||||
// Description: Implementation of M-of-N dual-control ceremony orchestration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates M-of-N dual-control signing ceremonies.
|
||||
/// </summary>
|
||||
public sealed class CeremonyOrchestrator : ICeremonyOrchestrator
|
||||
{
|
||||
private readonly ICeremonyRepository _repository;
|
||||
private readonly ICeremonyAuditSink _auditSink;
|
||||
private readonly ICeremonyApproverValidator _approverValidator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CeremonyOptions _options;
|
||||
private readonly ILogger<CeremonyOrchestrator> _logger;
|
||||
|
||||
public CeremonyOrchestrator(
|
||||
ICeremonyRepository repository,
|
||||
ICeremonyAuditSink auditSink,
|
||||
ICeremonyApproverValidator approverValidator,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<CeremonyOptions> options,
|
||||
ILogger<CeremonyOrchestrator> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
_approverValidator = approverValidator ?? throw new ArgumentNullException(nameof(approverValidator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CeremonyResult> CreateCeremonyAsync(
|
||||
CreateCeremonyRequest request,
|
||||
string initiator,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(initiator);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Ceremonies are disabled",
|
||||
ErrorCode = CeremonyErrorCode.InternalError
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var threshold = request.ThresholdOverride ?? _options.GetThreshold(request.OperationType);
|
||||
var expirationMinutes = request.ExpirationMinutesOverride ?? _options.GetExpirationMinutes(request.OperationType);
|
||||
|
||||
if (threshold < 1)
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid threshold: must be at least 1",
|
||||
ErrorCode = CeremonyErrorCode.InvalidThreshold
|
||||
};
|
||||
}
|
||||
|
||||
var ceremony = new Ceremony
|
||||
{
|
||||
CeremonyId = Guid.NewGuid(),
|
||||
OperationType = request.OperationType,
|
||||
Payload = request.Payload,
|
||||
ThresholdRequired = threshold,
|
||||
ThresholdReached = 0,
|
||||
State = CeremonyState.Pending,
|
||||
InitiatedBy = initiator,
|
||||
InitiatedAt = now,
|
||||
ExpiresAt = now.AddMinutes(expirationMinutes),
|
||||
Description = request.Description,
|
||||
Approvals = []
|
||||
};
|
||||
|
||||
var created = await _repository.CreateAsync(ceremony, cancellationToken);
|
||||
|
||||
await _auditSink.WriteAsync(new CeremonyInitiatedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.Initiated,
|
||||
CeremonyId = created.CeremonyId,
|
||||
OperationType = created.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = initiator,
|
||||
ThresholdRequired = threshold,
|
||||
ExpiresAt = created.ExpiresAt,
|
||||
Description = request.Description
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ceremony {CeremonyId} created for {OperationType} by {Initiator}, threshold {Threshold}, expires {ExpiresAt}",
|
||||
created.CeremonyId,
|
||||
created.OperationType,
|
||||
initiator,
|
||||
threshold,
|
||||
created.ExpiresAt.ToString("o", CultureInfo.InvariantCulture));
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = true,
|
||||
Ceremony = created
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CeremonyResult> ApproveCeremonyAsync(
|
||||
ApproveCeremonyRequest request,
|
||||
string approver,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approver);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var ceremony = await _repository.GetByIdAsync(request.CeremonyId, cancellationToken);
|
||||
if (ceremony is null)
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Ceremony not found",
|
||||
ErrorCode = CeremonyErrorCode.NotFound
|
||||
};
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (now >= ceremony.ExpiresAt)
|
||||
{
|
||||
await _auditSink.WriteAsync(new CeremonyApprovalRejectedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.ApprovalRejected,
|
||||
CeremonyId = ceremony.CeremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = approver,
|
||||
AttemptedApprover = approver,
|
||||
RejectionReason = "Ceremony has expired",
|
||||
ErrorCode = CeremonyErrorCode.Expired
|
||||
}, cancellationToken);
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Ceremony has expired",
|
||||
ErrorCode = CeremonyErrorCode.Expired
|
||||
};
|
||||
}
|
||||
|
||||
// Check state allows approval
|
||||
if (!CeremonyStateMachine.CanAcceptApproval(ceremony.State))
|
||||
{
|
||||
var errorCode = ceremony.State switch
|
||||
{
|
||||
CeremonyState.Executed => CeremonyErrorCode.AlreadyExecuted,
|
||||
CeremonyState.Expired => CeremonyErrorCode.Expired,
|
||||
CeremonyState.Cancelled => CeremonyErrorCode.Cancelled,
|
||||
_ => CeremonyErrorCode.InternalError
|
||||
};
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Ceremony cannot accept approvals in state {ceremony.State}",
|
||||
ErrorCode = errorCode
|
||||
};
|
||||
}
|
||||
|
||||
// Check for duplicate approval
|
||||
if (await _repository.HasApprovedAsync(request.CeremonyId, approver, cancellationToken))
|
||||
{
|
||||
await _auditSink.WriteAsync(new CeremonyApprovalRejectedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.ApprovalRejected,
|
||||
CeremonyId = ceremony.CeremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = approver,
|
||||
AttemptedApprover = approver,
|
||||
RejectionReason = "Approver has already approved this ceremony",
|
||||
ErrorCode = CeremonyErrorCode.DuplicateApproval
|
||||
}, cancellationToken);
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "You have already approved this ceremony",
|
||||
ErrorCode = CeremonyErrorCode.DuplicateApproval
|
||||
};
|
||||
}
|
||||
|
||||
// Validate approver authorization
|
||||
var validationResult = await _approverValidator.ValidateApproverAsync(
|
||||
approver,
|
||||
ceremony.OperationType,
|
||||
request.ApprovalSignature,
|
||||
cancellationToken);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
await _auditSink.WriteAsync(new CeremonyApprovalRejectedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.ApprovalRejected,
|
||||
CeremonyId = ceremony.CeremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = approver,
|
||||
AttemptedApprover = approver,
|
||||
RejectionReason = validationResult.Error ?? "Approver validation failed",
|
||||
ErrorCode = validationResult.ErrorCode ?? CeremonyErrorCode.UnauthorizedApprover
|
||||
}, cancellationToken);
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = validationResult.Error ?? "Approver validation failed",
|
||||
ErrorCode = validationResult.ErrorCode ?? CeremonyErrorCode.UnauthorizedApprover
|
||||
};
|
||||
}
|
||||
|
||||
// Add approval
|
||||
var approval = new CeremonyApproval
|
||||
{
|
||||
ApprovalId = Guid.NewGuid(),
|
||||
CeremonyId = request.CeremonyId,
|
||||
ApproverIdentity = approver,
|
||||
ApprovedAt = now,
|
||||
ApprovalSignature = request.ApprovalSignature,
|
||||
ApprovalReason = request.ApprovalReason,
|
||||
SigningKeyId = request.SigningKeyId,
|
||||
SignatureAlgorithm = request.SignatureAlgorithm
|
||||
};
|
||||
|
||||
await _repository.AddApprovalAsync(approval, cancellationToken);
|
||||
|
||||
// Compute new state
|
||||
var newThresholdReached = ceremony.ThresholdReached + 1;
|
||||
var newState = CeremonyStateMachine.ComputeStateAfterApproval(
|
||||
ceremony.State,
|
||||
ceremony.ThresholdRequired,
|
||||
newThresholdReached);
|
||||
|
||||
var updated = await _repository.UpdateStateAsync(
|
||||
ceremony.CeremonyId,
|
||||
newState,
|
||||
newThresholdReached,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var thresholdReached = newThresholdReached >= ceremony.ThresholdRequired;
|
||||
|
||||
await _auditSink.WriteAsync(new CeremonyApprovedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.Approved,
|
||||
CeremonyId = ceremony.CeremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = approver,
|
||||
Approver = approver,
|
||||
ApprovalCount = newThresholdReached,
|
||||
ThresholdRequired = ceremony.ThresholdRequired,
|
||||
ApprovalReason = request.ApprovalReason,
|
||||
ThresholdReached = thresholdReached
|
||||
}, cancellationToken);
|
||||
|
||||
if (thresholdReached)
|
||||
{
|
||||
await _auditSink.WriteAsync(new CeremonyApprovedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.ThresholdReached,
|
||||
CeremonyId = ceremony.CeremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = approver,
|
||||
Approver = approver,
|
||||
ApprovalCount = newThresholdReached,
|
||||
ThresholdRequired = ceremony.ThresholdRequired,
|
||||
ThresholdReached = true
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ceremony {CeremonyId} reached threshold {Threshold}, ready for execution",
|
||||
ceremony.CeremonyId,
|
||||
ceremony.ThresholdRequired);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ceremony {CeremonyId} approved by {Approver}, {Current}/{Required} approvals",
|
||||
ceremony.CeremonyId,
|
||||
approver,
|
||||
newThresholdReached,
|
||||
ceremony.ThresholdRequired);
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = true,
|
||||
Ceremony = updated
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Ceremony?> GetCeremonyAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetByIdAsync(ceremonyId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Ceremony>> ListCeremoniesAsync(
|
||||
CeremonyFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.ListAsync(filter, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CeremonyResult> ExecuteCeremonyAsync(
|
||||
Guid ceremonyId,
|
||||
string executor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(executor);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var ceremony = await _repository.GetByIdAsync(ceremonyId, cancellationToken);
|
||||
if (ceremony is null)
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Ceremony not found",
|
||||
ErrorCode = CeremonyErrorCode.NotFound
|
||||
};
|
||||
}
|
||||
|
||||
if (!CeremonyStateMachine.CanExecute(ceremony.State))
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Ceremony cannot be executed in state {ceremony.State}",
|
||||
ErrorCode = ceremony.State == CeremonyState.Executed
|
||||
? CeremonyErrorCode.AlreadyExecuted
|
||||
: CeremonyErrorCode.InternalError
|
||||
};
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (now >= ceremony.ExpiresAt)
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Ceremony execution window has expired",
|
||||
ErrorCode = CeremonyErrorCode.Expired
|
||||
};
|
||||
}
|
||||
|
||||
var updated = await _repository.UpdateStateAsync(
|
||||
ceremonyId,
|
||||
CeremonyState.Executed,
|
||||
ceremony.ThresholdReached,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
await _auditSink.WriteAsync(new CeremonyExecutedEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.Executed,
|
||||
CeremonyId = ceremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = executor,
|
||||
Executor = executor,
|
||||
TotalApprovals = ceremony.ThresholdReached,
|
||||
Success = true
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ceremony {CeremonyId} executed by {Executor}",
|
||||
ceremonyId,
|
||||
executor);
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = true,
|
||||
Ceremony = updated
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CeremonyResult> CancelCeremonyAsync(
|
||||
Guid ceremonyId,
|
||||
string canceller,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(canceller);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var ceremony = await _repository.GetByIdAsync(ceremonyId, cancellationToken);
|
||||
if (ceremony is null)
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Ceremony not found",
|
||||
ErrorCode = CeremonyErrorCode.NotFound
|
||||
};
|
||||
}
|
||||
|
||||
if (!CeremonyStateMachine.CanCancel(ceremony.State))
|
||||
{
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Ceremony cannot be cancelled in state {ceremony.State}",
|
||||
ErrorCode = CeremonyErrorCode.InternalError
|
||||
};
|
||||
}
|
||||
|
||||
var previousState = ceremony.State;
|
||||
|
||||
var updated = await _repository.UpdateStateAsync(
|
||||
ceremonyId,
|
||||
CeremonyState.Cancelled,
|
||||
ceremony.ThresholdReached,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await _auditSink.WriteAsync(new CeremonyCancelledEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.Cancelled,
|
||||
CeremonyId = ceremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = canceller,
|
||||
Reason = reason,
|
||||
StateAtCancellation = previousState,
|
||||
ApprovalsReceived = ceremony.ThresholdReached
|
||||
}, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ceremony {CeremonyId} cancelled by {Canceller}: {Reason}",
|
||||
ceremonyId,
|
||||
canceller,
|
||||
reason ?? "(no reason provided)");
|
||||
|
||||
return new CeremonyResult
|
||||
{
|
||||
Success = true,
|
||||
Ceremony = updated
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> ProcessExpiredCeremoniesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var expired = await _repository.GetExpiredCeremoniesAsync(now, cancellationToken);
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ids = new List<Guid>(expired.Count);
|
||||
foreach (var ceremony in expired)
|
||||
{
|
||||
ids.Add(ceremony.CeremonyId);
|
||||
|
||||
await _auditSink.WriteAsync(new CeremonyExpiredEvent
|
||||
{
|
||||
EventType = CeremonyAuditEvents.Expired,
|
||||
CeremonyId = ceremony.CeremonyId,
|
||||
OperationType = ceremony.OperationType,
|
||||
Timestamp = now,
|
||||
Actor = "system",
|
||||
ApprovalsReceived = ceremony.ThresholdReached,
|
||||
ThresholdRequired = ceremony.ThresholdRequired
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
var count = await _repository.MarkExpiredAsync(ids, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Marked {Count} ceremonies as expired", count);
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ceremony audit logging.
|
||||
/// </summary>
|
||||
public interface ICeremonyAuditSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes an audit event.
|
||||
/// </summary>
|
||||
Task WriteAsync(CeremonyAuditEvent auditEvent, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for validating ceremony approvers.
|
||||
/// </summary>
|
||||
public interface ICeremonyApproverValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates an approver for a ceremony operation.
|
||||
/// </summary>
|
||||
Task<ApproverValidationResult> ValidateApproverAsync(
|
||||
string approverIdentity,
|
||||
CeremonyOperationType operationType,
|
||||
byte[] signature,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of approver validation.
|
||||
/// </summary>
|
||||
public sealed record ApproverValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the approver is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if invalid.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if invalid.
|
||||
/// </summary>
|
||||
public CeremonyErrorCode? ErrorCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyStateMachine.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-003
|
||||
// Description: State machine for ceremony lifecycle management.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// Manages ceremony state transitions.
|
||||
/// </summary>
|
||||
public static class CeremonyStateMachine
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if a state transition is valid.
|
||||
/// </summary>
|
||||
/// <param name="currentState">Current ceremony state.</param>
|
||||
/// <param name="targetState">Target state.</param>
|
||||
/// <returns>True if transition is valid.</returns>
|
||||
public static bool IsValidTransition(CeremonyState currentState, CeremonyState targetState)
|
||||
{
|
||||
return (currentState, targetState) switch
|
||||
{
|
||||
// From Pending
|
||||
(CeremonyState.Pending, CeremonyState.PartiallyApproved) => true,
|
||||
(CeremonyState.Pending, CeremonyState.Approved) => true, // Direct approval if threshold = 1
|
||||
(CeremonyState.Pending, CeremonyState.Expired) => true,
|
||||
(CeremonyState.Pending, CeremonyState.Cancelled) => true,
|
||||
|
||||
// From PartiallyApproved
|
||||
(CeremonyState.PartiallyApproved, CeremonyState.PartiallyApproved) => true, // More approvals
|
||||
(CeremonyState.PartiallyApproved, CeremonyState.Approved) => true,
|
||||
(CeremonyState.PartiallyApproved, CeremonyState.Expired) => true,
|
||||
(CeremonyState.PartiallyApproved, CeremonyState.Cancelled) => true,
|
||||
|
||||
// From Approved
|
||||
(CeremonyState.Approved, CeremonyState.Executed) => true,
|
||||
(CeremonyState.Approved, CeremonyState.Expired) => true, // Execution window expired
|
||||
(CeremonyState.Approved, CeremonyState.Cancelled) => true,
|
||||
|
||||
// Terminal states - no transitions
|
||||
(CeremonyState.Executed, _) => false,
|
||||
(CeremonyState.Expired, _) => false,
|
||||
(CeremonyState.Cancelled, _) => false,
|
||||
|
||||
// Same state is not a transition
|
||||
_ when currentState == targetState => false,
|
||||
|
||||
// All other transitions are invalid
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the next state after an approval.
|
||||
/// </summary>
|
||||
/// <param name="currentState">Current ceremony state.</param>
|
||||
/// <param name="thresholdRequired">Number of approvals required.</param>
|
||||
/// <param name="thresholdReached">Number of approvals received (after this approval).</param>
|
||||
/// <returns>Next state.</returns>
|
||||
public static CeremonyState ComputeStateAfterApproval(
|
||||
CeremonyState currentState,
|
||||
int thresholdRequired,
|
||||
int thresholdReached)
|
||||
{
|
||||
if (currentState is CeremonyState.Executed or CeremonyState.Expired or CeremonyState.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot approve ceremony in state {currentState}");
|
||||
}
|
||||
|
||||
if (thresholdReached >= thresholdRequired)
|
||||
{
|
||||
return CeremonyState.Approved;
|
||||
}
|
||||
|
||||
return CeremonyState.PartiallyApproved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a ceremony can accept approvals.
|
||||
/// </summary>
|
||||
/// <param name="state">Current ceremony state.</param>
|
||||
/// <returns>True if approvals can be added.</returns>
|
||||
public static bool CanAcceptApproval(CeremonyState state)
|
||||
{
|
||||
return state is CeremonyState.Pending or CeremonyState.PartiallyApproved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a ceremony can be executed.
|
||||
/// </summary>
|
||||
/// <param name="state">Current ceremony state.</param>
|
||||
/// <returns>True if the ceremony can be executed.</returns>
|
||||
public static bool CanExecute(CeremonyState state)
|
||||
{
|
||||
return state == CeremonyState.Approved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a ceremony can be cancelled.
|
||||
/// </summary>
|
||||
/// <param name="state">Current ceremony state.</param>
|
||||
/// <returns>True if the ceremony can be cancelled.</returns>
|
||||
public static bool CanCancel(CeremonyState state)
|
||||
{
|
||||
return state is CeremonyState.Pending or CeremonyState.PartiallyApproved or CeremonyState.Approved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a ceremony is in a terminal state.
|
||||
/// </summary>
|
||||
/// <param name="state">Current ceremony state.</param>
|
||||
/// <returns>True if the ceremony is in a terminal state.</returns>
|
||||
public static bool IsTerminalState(CeremonyState state)
|
||||
{
|
||||
return state is CeremonyState.Executed or CeremonyState.Expired or CeremonyState.Cancelled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable description of the state.
|
||||
/// </summary>
|
||||
/// <param name="state">Ceremony state.</param>
|
||||
/// <returns>Human-readable description.</returns>
|
||||
public static string GetStateDescription(CeremonyState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
CeremonyState.Pending => "Awaiting approvals",
|
||||
CeremonyState.PartiallyApproved => "Some approvals received, awaiting more",
|
||||
CeremonyState.Approved => "All approvals received, ready for execution",
|
||||
CeremonyState.Executed => "Operation executed successfully",
|
||||
CeremonyState.Expired => "Ceremony expired before completion",
|
||||
CeremonyState.Cancelled => "Ceremony was cancelled",
|
||||
_ => "Unknown state"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICeremonyOrchestrator.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-002
|
||||
// Description: Interface for M-of-N dual-control ceremony orchestration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates M-of-N dual-control signing ceremonies.
|
||||
/// </summary>
|
||||
public interface ICeremonyOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ceremony for the specified operation.
|
||||
/// </summary>
|
||||
/// <param name="request">Ceremony creation request.</param>
|
||||
/// <param name="initiator">Identity of the ceremony initiator.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the created ceremony or error.</returns>
|
||||
Task<CeremonyResult> CreateCeremonyAsync(
|
||||
CreateCeremonyRequest request,
|
||||
string initiator,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Submits an approval for a ceremony.
|
||||
/// </summary>
|
||||
/// <param name="request">Approval request.</param>
|
||||
/// <param name="approver">Identity of the approver.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the updated ceremony or error.</returns>
|
||||
Task<CeremonyResult> ApproveCeremonyAsync(
|
||||
ApproveCeremonyRequest request,
|
||||
string approver,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a ceremony by ID.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The ceremony or null if not found.</returns>
|
||||
Task<Ceremony?> GetCeremonyAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists ceremonies with optional filters.
|
||||
/// </summary>
|
||||
/// <param name="filter">Optional filter criteria.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of ceremonies matching the filter.</returns>
|
||||
Task<IReadOnlyList<Ceremony>> ListCeremoniesAsync(
|
||||
CeremonyFilter? filter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes an approved ceremony.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony to execute.</param>
|
||||
/// <param name="executor">Identity of the executor.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the execution.</returns>
|
||||
Task<CeremonyResult> ExecuteCeremonyAsync(
|
||||
Guid ceremonyId,
|
||||
string executor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending ceremony.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony to cancel.</param>
|
||||
/// <param name="canceller">Identity of the canceller.</param>
|
||||
/// <param name="reason">Reason for cancellation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the cancellation.</returns>
|
||||
Task<CeremonyResult> CancelCeremonyAsync(
|
||||
Guid ceremonyId,
|
||||
string canceller,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Processes expired ceremonies (background task).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of ceremonies marked as expired.</returns>
|
||||
Task<int> ProcessExpiredCeremoniesAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter criteria for listing ceremonies.
|
||||
/// </summary>
|
||||
public sealed record CeremonyFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by state.
|
||||
/// </summary>
|
||||
public CeremonyState? State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by operation type.
|
||||
/// </summary>
|
||||
public CeremonyOperationType? OperationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by initiator.
|
||||
/// </summary>
|
||||
public string? InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by pending approver (shows ceremonies the user can approve).
|
||||
/// </summary>
|
||||
public string? PendingApprover { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter ceremonies initiated after this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? InitiatedAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter ceremonies initiated before this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? InitiatedBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include expired ceremonies.
|
||||
/// </summary>
|
||||
public bool IncludeExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int? Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID filter.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICeremonyRepository.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-009
|
||||
// Description: Repository interface for ceremony persistence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.Core.Ceremonies;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for ceremony persistence.
|
||||
/// </summary>
|
||||
public interface ICeremonyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new ceremony.
|
||||
/// </summary>
|
||||
/// <param name="ceremony">Ceremony to create.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Created ceremony with generated ID.</returns>
|
||||
Task<Ceremony> CreateAsync(
|
||||
Ceremony ceremony,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a ceremony by ID.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The ceremony or null if not found.</returns>
|
||||
Task<Ceremony?> GetByIdAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a ceremony's state and threshold.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony ID.</param>
|
||||
/// <param name="newState">New state.</param>
|
||||
/// <param name="thresholdReached">New threshold reached count.</param>
|
||||
/// <param name="executedAt">Execution timestamp if executed.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Updated ceremony.</returns>
|
||||
Task<Ceremony?> UpdateStateAsync(
|
||||
Guid ceremonyId,
|
||||
CeremonyState newState,
|
||||
int thresholdReached,
|
||||
DateTimeOffset? executedAt = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an approval to a ceremony.
|
||||
/// </summary>
|
||||
/// <param name="approval">Approval to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Created approval.</returns>
|
||||
Task<CeremonyApproval> AddApprovalAsync(
|
||||
CeremonyApproval approval,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an approver has already approved a ceremony.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony ID.</param>
|
||||
/// <param name="approverIdentity">Approver identity.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if already approved.</returns>
|
||||
Task<bool> HasApprovedAsync(
|
||||
Guid ceremonyId,
|
||||
string approverIdentity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets approvals for a ceremony.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyId">Ceremony ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of approvals.</returns>
|
||||
Task<IReadOnlyList<CeremonyApproval>> GetApprovalsAsync(
|
||||
Guid ceremonyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists ceremonies matching a filter.
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter criteria.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of ceremonies.</returns>
|
||||
Task<IReadOnlyList<Ceremony>> ListAsync(
|
||||
CeremonyFilter? filter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets ceremonies that have expired but are not yet marked as expired.
|
||||
/// </summary>
|
||||
/// <param name="asOf">Time to check expiration against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of expired ceremonies.</returns>
|
||||
Task<IReadOnlyList<Ceremony>> GetExpiredCeremoniesAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks ceremonies as expired in bulk.
|
||||
/// </summary>
|
||||
/// <param name="ceremonyIds">Ceremony IDs to expire.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of ceremonies updated.</returns>
|
||||
Task<int> MarkExpiredAsync(
|
||||
IEnumerable<Guid> ceremonyIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CeremonyStateMachineTests.cs
|
||||
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
|
||||
// Tasks: DUAL-011
|
||||
// Description: Unit tests for ceremony state machine.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Signer.Core.Ceremonies;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Ceremonies;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CeremonyStateMachineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, CeremonyState.PartiallyApproved, true)]
|
||||
[InlineData(CeremonyState.Pending, CeremonyState.Approved, true)]
|
||||
[InlineData(CeremonyState.Pending, CeremonyState.Expired, true)]
|
||||
[InlineData(CeremonyState.Pending, CeremonyState.Cancelled, true)]
|
||||
[InlineData(CeremonyState.Pending, CeremonyState.Executed, false)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.PartiallyApproved, true)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Approved, true)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Expired, true)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Cancelled, true)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.Approved, CeremonyState.Executed, true)]
|
||||
[InlineData(CeremonyState.Approved, CeremonyState.Expired, true)]
|
||||
[InlineData(CeremonyState.Approved, CeremonyState.Cancelled, true)]
|
||||
[InlineData(CeremonyState.Approved, CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.Approved, CeremonyState.PartiallyApproved, false)]
|
||||
[InlineData(CeremonyState.Executed, CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.Executed, CeremonyState.Cancelled, false)]
|
||||
[InlineData(CeremonyState.Expired, CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.Expired, CeremonyState.Approved, false)]
|
||||
[InlineData(CeremonyState.Cancelled, CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.Cancelled, CeremonyState.Approved, false)]
|
||||
public void IsValidTransition_ReturnsExpectedResult(
|
||||
CeremonyState currentState,
|
||||
CeremonyState targetState,
|
||||
bool expected)
|
||||
{
|
||||
var result = CeremonyStateMachine.IsValidTransition(currentState, targetState);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidTransition_SameState_ReturnsFalse()
|
||||
{
|
||||
foreach (var state in Enum.GetValues<CeremonyState>())
|
||||
{
|
||||
Assert.False(CeremonyStateMachine.IsValidTransition(state, state));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, 2, 1, CeremonyState.PartiallyApproved)]
|
||||
[InlineData(CeremonyState.Pending, 2, 2, CeremonyState.Approved)]
|
||||
[InlineData(CeremonyState.Pending, 1, 1, CeremonyState.Approved)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, 3, 2, CeremonyState.PartiallyApproved)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, 3, 3, CeremonyState.Approved)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, 2, 3, CeremonyState.Approved)] // Over threshold
|
||||
public void ComputeStateAfterApproval_ReturnsExpectedState(
|
||||
CeremonyState currentState,
|
||||
int thresholdRequired,
|
||||
int thresholdReached,
|
||||
CeremonyState expectedState)
|
||||
{
|
||||
var result = CeremonyStateMachine.ComputeStateAfterApproval(
|
||||
currentState,
|
||||
thresholdRequired,
|
||||
thresholdReached);
|
||||
|
||||
Assert.Equal(expectedState, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Executed)]
|
||||
[InlineData(CeremonyState.Expired)]
|
||||
[InlineData(CeremonyState.Cancelled)]
|
||||
public void ComputeStateAfterApproval_TerminalState_ThrowsException(CeremonyState state)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
CeremonyStateMachine.ComputeStateAfterApproval(state, 2, 1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, true)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, true)]
|
||||
[InlineData(CeremonyState.Approved, false)]
|
||||
[InlineData(CeremonyState.Executed, false)]
|
||||
[InlineData(CeremonyState.Expired, false)]
|
||||
[InlineData(CeremonyState.Cancelled, false)]
|
||||
public void CanAcceptApproval_ReturnsExpectedResult(CeremonyState state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, CeremonyStateMachine.CanAcceptApproval(state));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, false)]
|
||||
[InlineData(CeremonyState.Approved, true)]
|
||||
[InlineData(CeremonyState.Executed, false)]
|
||||
[InlineData(CeremonyState.Expired, false)]
|
||||
[InlineData(CeremonyState.Cancelled, false)]
|
||||
public void CanExecute_ReturnsExpectedResult(CeremonyState state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, CeremonyStateMachine.CanExecute(state));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, true)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, true)]
|
||||
[InlineData(CeremonyState.Approved, true)]
|
||||
[InlineData(CeremonyState.Executed, false)]
|
||||
[InlineData(CeremonyState.Expired, false)]
|
||||
[InlineData(CeremonyState.Cancelled, false)]
|
||||
public void CanCancel_ReturnsExpectedResult(CeremonyState state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, CeremonyStateMachine.CanCancel(state));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, false)]
|
||||
[InlineData(CeremonyState.PartiallyApproved, false)]
|
||||
[InlineData(CeremonyState.Approved, false)]
|
||||
[InlineData(CeremonyState.Executed, true)]
|
||||
[InlineData(CeremonyState.Expired, true)]
|
||||
[InlineData(CeremonyState.Cancelled, true)]
|
||||
public void IsTerminalState_ReturnsExpectedResult(CeremonyState state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, CeremonyStateMachine.IsTerminalState(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStateDescription_ReturnsNonEmptyString()
|
||||
{
|
||||
foreach (var state in Enum.GetValues<CeremonyState>())
|
||||
{
|
||||
var description = CeremonyStateMachine.GetStateDescription(state);
|
||||
Assert.False(string.IsNullOrWhiteSpace(description));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CeremonyState.Pending, "Awaiting approvals")]
|
||||
[InlineData(CeremonyState.Approved, "All approvals received, ready for execution")]
|
||||
[InlineData(CeremonyState.Executed, "Operation executed successfully")]
|
||||
[InlineData(CeremonyState.Expired, "Ceremony expired before completion")]
|
||||
public void GetStateDescription_ReturnsExpectedDescription(CeremonyState state, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CeremonyStateMachine.GetStateDescription(state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// <copyright file="PredicateTypesTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-002)
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Contract;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PredicateTypes classification and allowlist behavior.
|
||||
/// Sprint: SPRINT_20260112_015_SIGNER_path_witness_predicate (SIGNER-PW-002)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class PredicateTypesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.PathWitnessCanonical)]
|
||||
[InlineData(PredicateTypes.PathWitnessAlias1)]
|
||||
[InlineData(PredicateTypes.PathWitnessAlias2)]
|
||||
[InlineData(PredicateTypes.StellaOpsPathWitness)]
|
||||
public void IsPathWitnessType_ReturnsTrueForAllPathWitnessTypes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsPathWitnessType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"{predicateType} should be recognized as a path witness type");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.StellaOpsSbom)]
|
||||
[InlineData(PredicateTypes.StellaOpsVex)]
|
||||
[InlineData(PredicateTypes.StellaOpsPolicy)]
|
||||
[InlineData(PredicateTypes.SlsaProvenanceV1)]
|
||||
[InlineData("some-unknown-type")]
|
||||
public void IsPathWitnessType_ReturnsFalseForNonPathWitnessTypes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsPathWitnessType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse($"{predicateType} should not be recognized as a path witness type");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.PathWitnessCanonical)]
|
||||
[InlineData(PredicateTypes.PathWitnessAlias1)]
|
||||
[InlineData(PredicateTypes.PathWitnessAlias2)]
|
||||
[InlineData(PredicateTypes.StellaOpsPathWitness)]
|
||||
public void IsReachabilityRelatedType_ReturnsTrueForPathWitnessTypes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsReachabilityRelatedType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"{predicateType} should be classified as reachability-related");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.StellaOpsCallGraph)]
|
||||
[InlineData(PredicateTypes.StellaOpsReachability)]
|
||||
[InlineData(PredicateTypes.StellaOpsRuntimeSignals)]
|
||||
public void IsReachabilityRelatedType_ReturnsTrueForOtherReachabilityTypes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsReachabilityRelatedType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"{predicateType} should be classified as reachability-related");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllowedPredicateTypes_ContainsAllPathWitnessTypes()
|
||||
{
|
||||
// Act
|
||||
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes().ToList();
|
||||
|
||||
// Assert
|
||||
allowedTypes.Should().Contain(PredicateTypes.PathWitnessCanonical);
|
||||
allowedTypes.Should().Contain(PredicateTypes.PathWitnessAlias1);
|
||||
allowedTypes.Should().Contain(PredicateTypes.PathWitnessAlias2);
|
||||
allowedTypes.Should().Contain(PredicateTypes.StellaOpsPathWitness);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PredicateTypes.PathWitnessCanonical)]
|
||||
[InlineData(PredicateTypes.PathWitnessAlias1)]
|
||||
[InlineData(PredicateTypes.PathWitnessAlias2)]
|
||||
[InlineData(PredicateTypes.StellaOpsPathWitness)]
|
||||
public void IsAllowedPredicateType_ReturnsTrueForPathWitnessTypes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsAllowedPredicateType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"{predicateType} should be in the allowed predicate list");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://stella.ops/predicates/path-witness/v1")]
|
||||
[InlineData("https://stella.ops/pathWitness/v1")]
|
||||
[InlineData("https://stella.ops/other/predicate")]
|
||||
[InlineData("https://stella-ops.org/predicates/test")]
|
||||
public void IsStellaOpsType_RecognizesStellaOpsUriPrefixes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsStellaOpsType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"{predicateType} should be recognized as a StellaOps type");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("stella.ops/pathWitness@v1")]
|
||||
[InlineData("stella.ops/sbom@v1")]
|
||||
[InlineData("stella.ops/vex@v1")]
|
||||
public void IsStellaOpsType_RecognizesStellaOpsDotSyntax(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsStellaOpsType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"{predicateType} should be recognized as a StellaOps type");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://slsa.dev/provenance/v1")]
|
||||
[InlineData("https://in-toto.io/Statement/v1")]
|
||||
[InlineData("https://example.com/custom-predicate")]
|
||||
public void IsStellaOpsType_ReturnsFalseForNonStellaOpsTypes(string predicateType)
|
||||
{
|
||||
// Act
|
||||
var result = PredicateTypes.IsStellaOpsType(predicateType);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse($"{predicateType} should not be recognized as a StellaOps type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathWitnessConstants_HaveCorrectValues()
|
||||
{
|
||||
// Assert
|
||||
PredicateTypes.PathWitnessCanonical.Should().Be("https://stella.ops/predicates/path-witness/v1");
|
||||
PredicateTypes.PathWitnessAlias1.Should().Be("stella.ops/pathWitness@v1");
|
||||
PredicateTypes.PathWitnessAlias2.Should().Be("https://stella.ops/pathWitness/v1");
|
||||
PredicateTypes.StellaOpsPathWitness.Should().Be("stella.ops/pathWitness@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathWitnessAlias1_EqualsLegacyStellaOpsPathWitness()
|
||||
{
|
||||
// The alias should equal the legacy constant for backward compatibility
|
||||
PredicateTypes.PathWitnessAlias1.Should().Be(PredicateTypes.StellaOpsPathWitness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedTypes_NoDuplicates()
|
||||
{
|
||||
// Act
|
||||
var allowedTypes = PredicateTypes.GetAllowedPredicateTypes().ToList();
|
||||
var distinctTypes = allowedTypes.Distinct().ToList();
|
||||
|
||||
// Assert
|
||||
allowedTypes.Count.Should().Be(distinctTypes.Count, "allowed types should not have duplicates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedTypes_IsDeterministicallyOrdered()
|
||||
{
|
||||
// Act
|
||||
var types1 = PredicateTypes.GetAllowedPredicateTypes().ToList();
|
||||
var types2 = PredicateTypes.GetAllowedPredicateTypes().ToList();
|
||||
|
||||
// Assert - Same order on multiple calls
|
||||
types1.Should().BeEquivalentTo(types2, options => options.WithStrictOrdering());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user