sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -0,0 +1,564 @@
// -----------------------------------------------------------------------------
// CeremonyOrchestratorIntegrationTests.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Task: DUAL-012
// Description: Integration tests for multi-approver ceremony workflows.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Signer.Core.Ceremonies;
using Xunit;
namespace StellaOps.Signer.Tests.Ceremonies;
/// <summary>
/// Integration tests for dual-control ceremony workflows.
/// Tests full ceremony lifecycle including multi-approver scenarios.
/// </summary>
[Trait("Category", "Integration")]
public sealed class CeremonyOrchestratorIntegrationTests : IAsyncLifetime
{
private readonly Mock<ICeremonyRepository> _mockRepository;
private readonly Mock<ICeremonyAuditSink> _mockAuditSink;
private readonly Mock<ICeremonyApproverValidator> _mockApproverValidator;
private readonly MockTimeProvider _mockTimeProvider;
private readonly CeremonyOrchestrator _orchestrator;
private readonly Dictionary<Guid, Ceremony> _ceremoniesStore;
private readonly List<object> _auditEvents;
public CeremonyOrchestratorIntegrationTests()
{
_mockRepository = new Mock<ICeremonyRepository>();
_mockAuditSink = new Mock<ICeremonyAuditSink>();
_mockApproverValidator = new Mock<ICeremonyApproverValidator>();
_mockTimeProvider = new MockTimeProvider();
_ceremoniesStore = new Dictionary<Guid, Ceremony>();
_auditEvents = new List<object>();
var options = Options.Create(new CeremonyOptions
{
Enabled = true,
DefaultThreshold = 2,
DefaultExpirationMinutes = 60,
ValidApproverGroups = new List<string> { "signing-officers", "key-custodians" }
});
var logger = Mock.Of<ILogger<CeremonyOrchestrator>>();
SetupRepositoryMock();
SetupAuditSinkMock();
SetupApproverValidatorMock();
_orchestrator = new CeremonyOrchestrator(
_mockRepository.Object,
_mockAuditSink.Object,
_mockApproverValidator.Object,
_mockTimeProvider,
options,
logger);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
#region Full Workflow Tests
[Fact]
public async Task FullWorkflow_TwoOfTwo_CompletesSuccessfully()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{ \"keyId\": \"signing-key-001\" }",
ThresholdOverride = 2
};
// Act - Create ceremony
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
Assert.True(createResult.Success);
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Verify initial state
var ceremony = await _orchestrator.GetCeremonyAsync(ceremonyId);
Assert.NotNull(ceremony);
Assert.Equal(CeremonyState.Pending, ceremony.State);
// Act - First approval
var approval1Result = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver1@example.com",
ApprovalReason = "Reviewed and approved",
ApprovalSignature = "sig1_base64",
SigningKeyId = "approver1-key"
});
Assert.True(approval1Result.Success);
Assert.Equal(CeremonyState.PartiallyApproved, approval1Result.Ceremony!.State);
// Act - Second approval
var approval2Result = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver2@example.com",
ApprovalReason = "LGTM",
ApprovalSignature = "sig2_base64",
SigningKeyId = "approver2-key"
});
Assert.True(approval2Result.Success);
Assert.Equal(CeremonyState.Approved, approval2Result.Ceremony!.State);
// Act - Execute
var executeResult = await _orchestrator.ExecuteCeremonyAsync(ceremonyId, "executor@example.com");
Assert.True(executeResult.Success);
Assert.Equal(CeremonyState.Executed, executeResult.Ceremony!.State);
// Verify audit trail
Assert.Contains(_auditEvents, e => e.GetType().Name.Contains("Initiated"));
Assert.Contains(_auditEvents, e => e.GetType().Name.Contains("Approved"));
Assert.Contains(_auditEvents, e => e.GetType().Name.Contains("Executed"));
}
[Fact]
public async Task FullWorkflow_ThreeOfFive_CompletesAfterThirdApproval()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyGeneration,
OperationPayload = "{ \"algorithm\": \"ed25519\" }",
ThresholdOverride = 3
};
// Act - Create ceremony
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
Assert.True(createResult.Success);
var ceremonyId = createResult.Ceremony!.CeremonyId;
// First two approvals should keep in PartiallyApproved
for (int i = 1; i <= 2; i++)
{
var result = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = $"approver{i}@example.com",
ApprovalReason = $"Approval {i}",
ApprovalSignature = $"sig{i}_base64",
SigningKeyId = $"approver{i}-key"
});
Assert.True(result.Success);
Assert.Equal(CeremonyState.PartiallyApproved, result.Ceremony!.State);
}
// Third approval should move to Approved
var finalApproval = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver3@example.com",
ApprovalReason = "Final approval",
ApprovalSignature = "sig3_base64",
SigningKeyId = "approver3-key"
});
Assert.True(finalApproval.Success);
Assert.Equal(CeremonyState.Approved, finalApproval.Ceremony!.State);
Assert.Equal(3, finalApproval.Ceremony.Approvals.Count);
}
[Fact]
public async Task FullWorkflow_SingleApprover_ApprovedImmediately()
{
// Arrange - threshold of 1
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{ \"keyId\": \"minor-key\" }",
ThresholdOverride = 1
};
// Create
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
Assert.True(createResult.Success);
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Single approval should immediately move to Approved
var approvalResult = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "Approved",
ApprovalSignature = "sig_base64",
SigningKeyId = "approver-key"
});
Assert.True(approvalResult.Success);
Assert.Equal(CeremonyState.Approved, approvalResult.Ceremony!.State);
}
#endregion
#region Duplicate Approval Tests
[Fact]
public async Task DuplicateApproval_SameApprover_IsRejected()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}",
ThresholdOverride = 2
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// First approval succeeds
var approval1 = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "First",
ApprovalSignature = "sig1",
SigningKeyId = "key1"
});
Assert.True(approval1.Success);
// Second approval from same approver should fail
var approval2 = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "Second",
ApprovalSignature = "sig2",
SigningKeyId = "key1"
});
Assert.False(approval2.Success);
Assert.Equal(CeremonyErrorCode.DuplicateApproval, approval2.ErrorCode);
}
#endregion
#region Expiration Tests
[Fact]
public async Task ExpiredCeremony_CannotBeApproved()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}",
ExpirationMinutesOverride = 30
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Advance time past expiration
_mockTimeProvider.Advance(TimeSpan.FromMinutes(31));
// Process expirations
await _orchestrator.ProcessExpiredCeremoniesAsync();
// Attempt approval should fail
var approval = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "Late approval",
ApprovalSignature = "sig",
SigningKeyId = "key"
});
Assert.False(approval.Success);
Assert.Equal(CeremonyErrorCode.InvalidState, approval.ErrorCode);
}
[Fact]
public async Task ExpiredCeremony_CannotBeExecuted()
{
// Arrange - create and fully approve
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}",
ThresholdOverride = 1,
ExpirationMinutesOverride = 30
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "Approved",
ApprovalSignature = "sig",
SigningKeyId = "key"
});
// Advance time past expiration
_mockTimeProvider.Advance(TimeSpan.FromMinutes(31));
await _orchestrator.ProcessExpiredCeremoniesAsync();
// Attempt execution should fail
var executeResult = await _orchestrator.ExecuteCeremonyAsync(ceremonyId, "executor@example.com");
Assert.False(executeResult.Success);
}
#endregion
#region Cancellation Tests
[Fact]
public async Task CancelledCeremony_CannotBeApproved()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}"
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Cancel
var cancelResult = await _orchestrator.CancelCeremonyAsync(ceremonyId, "admin@example.com", "Cancelled for testing");
Assert.True(cancelResult.Success);
// Attempt approval should fail
var approval = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "Too late",
ApprovalSignature = "sig",
SigningKeyId = "key"
});
Assert.False(approval.Success);
Assert.Equal(CeremonyErrorCode.InvalidState, approval.ErrorCode);
}
[Fact]
public async Task PartiallyApprovedCeremony_CanBeCancelled()
{
// Arrange
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}",
ThresholdOverride = 2
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Add one approval
await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "approver@example.com",
ApprovalReason = "First approval",
ApprovalSignature = "sig",
SigningKeyId = "key"
});
// Cancel should succeed
var cancelResult = await _orchestrator.CancelCeremonyAsync(ceremonyId, "admin@example.com", "Changed plans");
Assert.True(cancelResult.Success);
Assert.Equal(CeremonyState.Cancelled, cancelResult.Ceremony!.State);
}
#endregion
#region Audit Trail Tests
[Fact]
public async Task FullWorkflow_GeneratesCompleteAuditTrail()
{
// Arrange
_auditEvents.Clear();
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}",
ThresholdOverride = 2
};
// Act - full workflow
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
await _orchestrator.ApproveCeremonyAsync(ceremonyId, new CeremonyApprovalRequest
{
ApproverIdentity = "approver1@example.com",
ApprovalReason = "OK",
ApprovalSignature = "sig1",
SigningKeyId = "key1"
});
await _orchestrator.ApproveCeremonyAsync(ceremonyId, new CeremonyApprovalRequest
{
ApproverIdentity = "approver2@example.com",
ApprovalReason = "OK",
ApprovalSignature = "sig2",
SigningKeyId = "key2"
});
await _orchestrator.ExecuteCeremonyAsync(ceremonyId, "executor@example.com");
// Assert - verify audit events count
// Should have: initiated + 2 approved + executed = 4 events
Assert.True(_auditEvents.Count >= 4, $"Expected at least 4 audit events, got {_auditEvents.Count}");
}
#endregion
#region Approver Validation Tests
[Fact]
public async Task InvalidApprover_IsRejected()
{
// Arrange - set up validator to reject specific approver
_mockApproverValidator
.Setup(v => v.ValidateApproverAsync(
It.Is<string>(s => s == "invalid@example.com"),
It.IsAny<CeremonyOperationType>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ApproverValidationResult
{
IsValid = false,
Error = "Approver not in signing-officers group"
});
var request = new CreateCeremonyRequest
{
OperationType = CeremonyOperationType.KeyRotation,
OperationPayload = "{}"
};
var createResult = await _orchestrator.CreateCeremonyAsync(request, "initiator@example.com");
var ceremonyId = createResult.Ceremony!.CeremonyId;
// Act
var approval = await _orchestrator.ApproveCeremonyAsync(
ceremonyId,
new CeremonyApprovalRequest
{
ApproverIdentity = "invalid@example.com",
ApprovalReason = "Unauthorized",
ApprovalSignature = "sig",
SigningKeyId = "key"
});
// Assert
Assert.False(approval.Success);
Assert.Equal(CeremonyErrorCode.UnauthorizedApprover, approval.ErrorCode);
}
#endregion
#region Setup Helpers
private void SetupRepositoryMock()
{
_mockRepository
.Setup(r => r.CreateAsync(It.IsAny<Ceremony>(), It.IsAny<CancellationToken>()))
.Returns((Ceremony c, CancellationToken _) =>
{
_ceremoniesStore[c.CeremonyId] = c;
return Task.FromResult(c);
});
_mockRepository
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Returns((Guid id, CancellationToken _) =>
{
_ceremoniesStore.TryGetValue(id, out var ceremony);
return Task.FromResult(ceremony);
});
_mockRepository
.Setup(r => r.UpdateAsync(It.IsAny<Ceremony>(), It.IsAny<CancellationToken>()))
.Returns((Ceremony c, CancellationToken _) =>
{
_ceremoniesStore[c.CeremonyId] = c;
return Task.FromResult(c);
});
_mockRepository
.Setup(r => r.ListAsync(It.IsAny<CeremonyFilter>(), It.IsAny<CancellationToken>()))
.Returns((CeremonyFilter filter, CancellationToken _) =>
{
var query = _ceremoniesStore.Values.AsEnumerable();
if (filter?.States != null && filter.States.Any())
query = query.Where(c => filter.States.Contains(c.State));
if (filter?.OperationType != null)
query = query.Where(c => c.OperationType == filter.OperationType);
return Task.FromResult(query.ToList() as IReadOnlyList<Ceremony>);
});
}
private void SetupAuditSinkMock()
{
_mockAuditSink
.Setup(a => a.WriteAsync(It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Returns((object evt, CancellationToken _) =>
{
_auditEvents.Add(evt);
return Task.CompletedTask;
});
}
private void SetupApproverValidatorMock()
{
// Default: all approvers valid
_mockApproverValidator
.Setup(v => v.ValidateApproverAsync(
It.IsAny<string>(),
It.IsAny<CeremonyOperationType>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ApproverValidationResult { IsValid = true });
}
#endregion
}
/// <summary>
/// Mock time provider for testing time-dependent behavior.
/// </summary>
internal sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetNow(DateTimeOffset now) => _now = now;
}

View File

@@ -0,0 +1,566 @@
// -----------------------------------------------------------------------------
// CeremonyEndpoints.cs
// Sprint: SPRINT_20260112_018_SIGNER_dual_control_ceremonies
// Tasks: DUAL-010
// Description: API endpoints for dual-control signing ceremonies.
// -----------------------------------------------------------------------------
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.Signer.Core.Ceremonies;
using StellaOps.Signer.WebService.Contracts;
namespace StellaOps.Signer.WebService.Endpoints;
/// <summary>
/// API endpoints for M-of-N dual-control signing ceremonies.
/// </summary>
public static class CeremonyEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
/// <summary>
/// Maps ceremony endpoints to the endpoint route builder.
/// </summary>
public static IEndpointRouteBuilder MapCeremonyEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/ceremonies")
.WithTags("Ceremonies")
.RequireAuthorization("ceremony:read");
// Create ceremony
group.MapPost("/", CreateCeremonyAsync)
.WithName("CreateCeremony")
.WithSummary("Create a new signing ceremony")
.RequireAuthorization("ceremony:create")
.Produces<CeremonyResponseDto>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden);
// List ceremonies
group.MapGet("/", ListCeremoniesAsync)
.WithName("ListCeremonies")
.WithSummary("List ceremonies with optional filters")
.Produces<CeremonyListResponseDto>(StatusCodes.Status200OK);
// Get ceremony by ID
group.MapGet("/{ceremonyId:guid}", GetCeremonyAsync)
.WithName("GetCeremony")
.WithSummary("Get a ceremony by ID")
.Produces<CeremonyResponseDto>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound);
// Submit approval
group.MapPost("/{ceremonyId:guid}/approve", ApproveCeremonyAsync)
.WithName("ApproveCeremony")
.WithSummary("Submit an approval for a ceremony")
.RequireAuthorization("ceremony:approve")
.Produces<CeremonyResponseDto>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict);
// Execute ceremony
group.MapPost("/{ceremonyId:guid}/execute", ExecuteCeremonyAsync)
.WithName("ExecuteCeremony")
.WithSummary("Execute an approved ceremony")
.RequireAuthorization("ceremony:execute")
.Produces<CeremonyResponseDto>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict);
// Cancel ceremony
group.MapDelete("/{ceremonyId:guid}", CancelCeremonyAsync)
.WithName("CancelCeremony")
.WithSummary("Cancel a pending ceremony")
.RequireAuthorization("ceremony:cancel")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict);
return endpoints;
}
/// <summary>
/// POST /api/v1/ceremonies - Create a new ceremony.
/// </summary>
private static async Task<IResult> CreateCeremonyAsync(
HttpContext httpContext,
[FromBody] CreateCeremonyRequestDto request,
ICeremonyOrchestrator orchestrator,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
var logger = loggerFactory.CreateLogger("CeremonyEndpoints.CreateCeremony");
var initiator = GetCallerIdentity(httpContext);
logger.LogInformation(
"Creating ceremony: Type={OperationType}, Initiator={Initiator}",
request.OperationType, initiator);
var ceremonyRequest = new CreateCeremonyRequest
{
OperationType = MapOperationType(request.OperationType),
Payload = MapPayload(request.Payload),
ThresholdRequired = request.ThresholdRequired,
TimeoutMinutes = request.TimeoutMinutes ?? 60,
Description = request.Description,
TenantId = request.TenantId,
};
var result = await orchestrator.CreateCeremonyAsync(
ceremonyRequest,
initiator,
cancellationToken);
if (!result.Success)
{
logger.LogWarning("Failed to create ceremony: {Error}", result.Error);
return CreateProblem(result.ErrorCode ?? "ceremony_creation_failed", result.Error!, StatusCodes.Status400BadRequest);
}
var response = MapToResponseDto(result.Ceremony!);
return Results.Created($"/api/v1/ceremonies/{result.Ceremony!.CeremonyId}", response);
}
/// <summary>
/// GET /api/v1/ceremonies - List ceremonies.
/// </summary>
private static async Task<IResult> ListCeremoniesAsync(
HttpContext httpContext,
ICeremonyOrchestrator orchestrator,
[FromQuery] string? state,
[FromQuery] string? operationType,
[FromQuery] string? initiatedBy,
[FromQuery] string? tenantId,
[FromQuery] int? limit,
[FromQuery] int? offset,
CancellationToken cancellationToken)
{
var filter = new CeremonyFilter
{
State = ParseState(state),
OperationType = ParseOperationType(operationType),
InitiatedBy = initiatedBy,
TenantId = tenantId,
Limit = limit ?? 50,
Offset = offset ?? 0,
};
var ceremonies = await orchestrator.ListCeremoniesAsync(filter, cancellationToken);
var response = new CeremonyListResponseDto
{
Ceremonies = ceremonies.Select(MapToResponseDto).ToList(),
TotalCount = ceremonies.Count,
Limit = filter.Limit,
Offset = filter.Offset,
};
return Results.Ok(response);
}
/// <summary>
/// GET /api/v1/ceremonies/{ceremonyId} - Get ceremony by ID.
/// </summary>
private static async Task<IResult> GetCeremonyAsync(
HttpContext httpContext,
Guid ceremonyId,
ICeremonyOrchestrator orchestrator,
CancellationToken cancellationToken)
{
var ceremony = await orchestrator.GetCeremonyAsync(ceremonyId, cancellationToken);
if (ceremony == null)
{
return CreateProblem("ceremony_not_found", $"Ceremony {ceremonyId} not found.", StatusCodes.Status404NotFound);
}
return Results.Ok(MapToResponseDto(ceremony));
}
/// <summary>
/// POST /api/v1/ceremonies/{ceremonyId}/approve - Submit approval.
/// </summary>
private static async Task<IResult> ApproveCeremonyAsync(
HttpContext httpContext,
Guid ceremonyId,
[FromBody] ApproveCeremonyRequestDto request,
ICeremonyOrchestrator orchestrator,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
var logger = loggerFactory.CreateLogger("CeremonyEndpoints.ApproveCeremony");
var approver = GetCallerIdentity(httpContext);
logger.LogInformation(
"Approving ceremony: CeremonyId={CeremonyId}, Approver={Approver}",
ceremonyId, approver);
var approvalRequest = new ApproveCeremonyRequest
{
CeremonyId = ceremonyId,
Reason = request.Reason,
Signature = request.Signature,
SigningKeyId = request.SigningKeyId,
};
var result = await orchestrator.ApproveCeremonyAsync(
approvalRequest,
approver,
cancellationToken);
if (!result.Success)
{
var statusCode = result.ErrorCode switch
{
"ceremony_not_found" => StatusCodes.Status404NotFound,
"already_approved" or "invalid_state" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest,
};
logger.LogWarning("Failed to approve ceremony {CeremonyId}: {Error}", ceremonyId, result.Error);
return CreateProblem(result.ErrorCode ?? "approval_failed", result.Error!, statusCode);
}
return Results.Ok(MapToResponseDto(result.Ceremony!));
}
/// <summary>
/// POST /api/v1/ceremonies/{ceremonyId}/execute - Execute approved ceremony.
/// </summary>
private static async Task<IResult> ExecuteCeremonyAsync(
HttpContext httpContext,
Guid ceremonyId,
ICeremonyOrchestrator orchestrator,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
var logger = loggerFactory.CreateLogger("CeremonyEndpoints.ExecuteCeremony");
var executor = GetCallerIdentity(httpContext);
logger.LogInformation(
"Executing ceremony: CeremonyId={CeremonyId}, Executor={Executor}",
ceremonyId, executor);
var result = await orchestrator.ExecuteCeremonyAsync(
ceremonyId,
executor,
cancellationToken);
if (!result.Success)
{
var statusCode = result.ErrorCode switch
{
"ceremony_not_found" => StatusCodes.Status404NotFound,
"not_approved" or "already_executed" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest,
};
logger.LogWarning("Failed to execute ceremony {CeremonyId}: {Error}", ceremonyId, result.Error);
return CreateProblem(result.ErrorCode ?? "execution_failed", result.Error!, statusCode);
}
return Results.Ok(MapToResponseDto(result.Ceremony!));
}
/// <summary>
/// DELETE /api/v1/ceremonies/{ceremonyId} - Cancel ceremony.
/// </summary>
private static async Task<IResult> CancelCeremonyAsync(
HttpContext httpContext,
Guid ceremonyId,
[FromQuery] string? reason,
ICeremonyOrchestrator orchestrator,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
var logger = loggerFactory.CreateLogger("CeremonyEndpoints.CancelCeremony");
var canceller = GetCallerIdentity(httpContext);
logger.LogInformation(
"Cancelling ceremony: CeremonyId={CeremonyId}, Canceller={Canceller}",
ceremonyId, canceller);
var result = await orchestrator.CancelCeremonyAsync(
ceremonyId,
canceller,
reason,
cancellationToken);
if (!result.Success)
{
var statusCode = result.ErrorCode switch
{
"ceremony_not_found" => StatusCodes.Status404NotFound,
"cannot_cancel" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest,
};
logger.LogWarning("Failed to cancel ceremony {CeremonyId}: {Error}", ceremonyId, result.Error);
return CreateProblem(result.ErrorCode ?? "cancellation_failed", result.Error!, statusCode);
}
return Results.NoContent();
}
// ═══════════════════════════════════════════════════════════════════════════
// Helper Methods
// ═══════════════════════════════════════════════════════════════════════════
private static string GetCallerIdentity(HttpContext httpContext)
{
return httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? httpContext.User.FindFirst("sub")?.Value
?? "anonymous";
}
private static CeremonyOperationType MapOperationType(string operationType)
{
return operationType.ToLowerInvariant() switch
{
"keygeneration" or "key_generation" => CeremonyOperationType.KeyGeneration,
"keyrotation" or "key_rotation" => CeremonyOperationType.KeyRotation,
"keyrevocation" or "key_revocation" => CeremonyOperationType.KeyRevocation,
"keyexport" or "key_export" => CeremonyOperationType.KeyExport,
"keyimport" or "key_import" => CeremonyOperationType.KeyImport,
"keyrecovery" or "key_recovery" => CeremonyOperationType.KeyRecovery,
_ => throw new ArgumentException($"Unknown operation type: {operationType}"),
};
}
private static CeremonyState? ParseState(string? state)
{
if (string.IsNullOrEmpty(state)) return null;
return state.ToLowerInvariant() switch
{
"pending" => CeremonyState.Pending,
"partiallyapproved" or "partially_approved" => CeremonyState.PartiallyApproved,
"approved" => CeremonyState.Approved,
"executed" => CeremonyState.Executed,
"expired" => CeremonyState.Expired,
"cancelled" => CeremonyState.Cancelled,
_ => null,
};
}
private static CeremonyOperationType? ParseOperationType(string? operationType)
{
if (string.IsNullOrEmpty(operationType)) return null;
try
{
return MapOperationType(operationType);
}
catch
{
return null;
}
}
private static CeremonyOperationPayload MapPayload(CreateCeremonyPayloadDto? dto)
{
if (dto == null) return new CeremonyOperationPayload();
return new CeremonyOperationPayload
{
KeyId = dto.KeyId,
Algorithm = dto.Algorithm,
KeySize = dto.KeySize,
KeyUsages = dto.KeyUsages,
Reason = dto.Reason,
Metadata = dto.Metadata,
};
}
private static CeremonyResponseDto MapToResponseDto(Ceremony ceremony)
{
return new CeremonyResponseDto
{
CeremonyId = ceremony.CeremonyId,
OperationType = ceremony.OperationType.ToString(),
State = ceremony.State.ToString(),
ThresholdRequired = ceremony.ThresholdRequired,
ThresholdReached = ceremony.ThresholdReached,
InitiatedBy = ceremony.InitiatedBy,
InitiatedAt = ceremony.InitiatedAt,
ExpiresAt = ceremony.ExpiresAt,
ExecutedAt = ceremony.ExecutedAt,
Description = ceremony.Description,
TenantId = ceremony.TenantId,
Payload = MapPayloadToDto(ceremony.Payload),
Approvals = ceremony.Approvals.Select(MapApprovalToDto).ToList(),
};
}
private static CeremonyPayloadDto MapPayloadToDto(CeremonyOperationPayload payload)
{
return new CeremonyPayloadDto
{
KeyId = payload.KeyId,
Algorithm = payload.Algorithm,
KeySize = payload.KeySize,
KeyUsages = payload.KeyUsages?.ToList(),
Reason = payload.Reason,
Metadata = payload.Metadata?.ToDictionary(x => x.Key, x => x.Value),
};
}
private static CeremonyApprovalDto MapApprovalToDto(CeremonyApproval approval)
{
return new CeremonyApprovalDto
{
ApprovalId = approval.ApprovalId,
ApproverIdentity = approval.ApproverIdentity,
ApprovedAt = approval.ApprovedAt,
Reason = approval.ApprovalReason,
};
}
private static IResult CreateProblem(string code, string detail, int statusCode)
{
return Results.Problem(
detail: detail,
statusCode: statusCode,
title: code,
type: $"https://stellaops.io/errors/{code}");
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DTO Classes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Request to create a new ceremony.
/// </summary>
public sealed record CreateCeremonyRequestDto
{
/// <summary>
/// Type of operation (KeyGeneration, KeyRotation, KeyRevocation, KeyExport, KeyImport, KeyRecovery).
/// </summary>
public required string OperationType { get; init; }
/// <summary>
/// Operation-specific payload.
/// </summary>
public CreateCeremonyPayloadDto? Payload { get; init; }
/// <summary>
/// Number of approvals required.
/// </summary>
public required int ThresholdRequired { get; init; }
/// <summary>
/// Ceremony timeout in minutes (default: 60).
/// </summary>
public int? TimeoutMinutes { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Tenant ID for multi-tenant deployments.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Operation payload for ceremony creation.
/// </summary>
public sealed record CreateCeremonyPayloadDto
{
public string? KeyId { get; init; }
public string? Algorithm { get; init; }
public int? KeySize { get; init; }
public List<string>? KeyUsages { get; init; }
public string? Reason { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to approve a ceremony.
/// </summary>
public sealed record ApproveCeremonyRequestDto
{
/// <summary>
/// Reason for approval.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Approval signature (base64 encoded).
/// </summary>
public string? Signature { get; init; }
/// <summary>
/// Key ID used for signing the approval.
/// </summary>
public string? SigningKeyId { get; init; }
}
/// <summary>
/// Response containing ceremony details.
/// </summary>
public sealed record CeremonyResponseDto
{
public required Guid CeremonyId { get; init; }
public required string OperationType { get; init; }
public required string State { get; init; }
public required int ThresholdRequired { get; init; }
public required int ThresholdReached { get; init; }
public required string InitiatedBy { get; init; }
public required DateTimeOffset InitiatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset? ExecutedAt { get; init; }
public string? Description { get; init; }
public string? TenantId { get; init; }
public required CeremonyPayloadDto Payload { get; init; }
public required List<CeremonyApprovalDto> Approvals { get; init; }
}
/// <summary>
/// Ceremony payload in response.
/// </summary>
public sealed record CeremonyPayloadDto
{
public string? KeyId { get; init; }
public string? Algorithm { get; init; }
public int? KeySize { get; init; }
public List<string>? KeyUsages { get; init; }
public string? Reason { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Approval information in response.
/// </summary>
public sealed record CeremonyApprovalDto
{
public required Guid ApprovalId { get; init; }
public required string ApproverIdentity { get; init; }
public required DateTimeOffset ApprovedAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Response containing list of ceremonies.
/// </summary>
public sealed record CeremonyListResponseDto
{
public required List<CeremonyResponseDto> Ceremonies { get; init; }
public required int TotalCount { get; init; }
public required int Limit { get; init; }
public required int Offset { get; init; }
}