old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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"
};
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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());
}
}