sprints completion. new product advisories prepared
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user