Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners. - Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns. - Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures. - Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationPointerRequest(
|
||||
string FindingId,
|
||||
string AttestationType,
|
||||
string Relationship,
|
||||
AttestationRefDto AttestationRef,
|
||||
VerificationResultDto? VerificationResult = null,
|
||||
string? CreatedBy = null,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation artifact.
|
||||
/// </summary>
|
||||
public sealed record AttestationRefDto(
|
||||
string Digest,
|
||||
string? AttestationId = null,
|
||||
string? StorageUri = null,
|
||||
string? PayloadType = null,
|
||||
string? PredicateType = null,
|
||||
IReadOnlyList<string>? SubjectDigests = null,
|
||||
SignerInfoDto? SignerInfo = null,
|
||||
RekorEntryRefDto? RekorEntry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Information about the attestation signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfoDto(
|
||||
string? KeyId = null,
|
||||
string? Issuer = null,
|
||||
string? Subject = null,
|
||||
IReadOnlyList<string>? CertificateChain = null,
|
||||
DateTimeOffset? SignedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryRefDto(
|
||||
long? LogIndex = null,
|
||||
string? LogId = null,
|
||||
string? Uuid = null,
|
||||
long? IntegratedTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationResultDto(
|
||||
bool Verified,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? Verifier = null,
|
||||
string? VerifierVersion = null,
|
||||
string? PolicyRef = null,
|
||||
IReadOnlyList<VerificationCheckDto>? Checks = null,
|
||||
IReadOnlyList<string>? Warnings = null,
|
||||
IReadOnlyList<string>? Errors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheckDto(
|
||||
string CheckType,
|
||||
bool Passed,
|
||||
string? Details = null,
|
||||
Dictionary<string, object>? Evidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response for creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationPointerResponse(
|
||||
bool Success,
|
||||
string? PointerId,
|
||||
string? LedgerEventId,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Response for getting attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerResponse(
|
||||
string PointerId,
|
||||
string FindingId,
|
||||
string AttestationType,
|
||||
string Relationship,
|
||||
AttestationRefDto AttestationRef,
|
||||
VerificationResultDto? VerificationResult,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
Dictionary<string, object>? Metadata,
|
||||
string? LedgerEventId);
|
||||
|
||||
/// <summary>
|
||||
/// Response for attestation summary.
|
||||
/// </summary>
|
||||
public sealed record AttestationSummaryResponse(
|
||||
string FindingId,
|
||||
int AttestationCount,
|
||||
int VerifiedCount,
|
||||
DateTimeOffset? LatestAttestation,
|
||||
IReadOnlyList<string> AttestationTypes,
|
||||
string OverallVerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerSearchRequest(
|
||||
IReadOnlyList<string>? FindingIds = null,
|
||||
IReadOnlyList<string>? AttestationTypes = null,
|
||||
string? VerificationStatus = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
string? SignerIdentity = null,
|
||||
string? PredicateType = null,
|
||||
int Limit = 100,
|
||||
int Offset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Response for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerSearchResponse(
|
||||
IReadOnlyList<AttestationPointerResponse> Pointers,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update verification result.
|
||||
/// </summary>
|
||||
public sealed record UpdateVerificationResultRequest(
|
||||
VerificationResultDto VerificationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Mapping extensions for attestation pointer DTOs.
|
||||
/// </summary>
|
||||
public static class AttestationPointerMappings
|
||||
{
|
||||
public static AttestationPointerInput ToInput(this CreateAttestationPointerRequest request, string tenantId)
|
||||
{
|
||||
if (!Enum.TryParse<AttestationType>(request.AttestationType, ignoreCase: true, out var attestationType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid attestation type: {request.AttestationType}");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<AttestationRelationship>(request.Relationship, ignoreCase: true, out var relationship))
|
||||
{
|
||||
throw new ArgumentException($"Invalid relationship: {request.Relationship}");
|
||||
}
|
||||
|
||||
return new AttestationPointerInput(
|
||||
tenantId,
|
||||
request.FindingId,
|
||||
attestationType,
|
||||
relationship,
|
||||
request.AttestationRef.ToModel(),
|
||||
request.VerificationResult?.ToModel(),
|
||||
request.CreatedBy,
|
||||
request.Metadata);
|
||||
}
|
||||
|
||||
public static AttestationRef ToModel(this AttestationRefDto dto)
|
||||
{
|
||||
return new AttestationRef(
|
||||
dto.Digest,
|
||||
dto.AttestationId is not null ? Guid.Parse(dto.AttestationId) : null,
|
||||
dto.StorageUri,
|
||||
dto.PayloadType,
|
||||
dto.PredicateType,
|
||||
dto.SubjectDigests,
|
||||
dto.SignerInfo?.ToModel(),
|
||||
dto.RekorEntry?.ToModel());
|
||||
}
|
||||
|
||||
public static SignerInfo ToModel(this SignerInfoDto dto)
|
||||
{
|
||||
return new SignerInfo(
|
||||
dto.KeyId,
|
||||
dto.Issuer,
|
||||
dto.Subject,
|
||||
dto.CertificateChain,
|
||||
dto.SignedAt);
|
||||
}
|
||||
|
||||
public static RekorEntryRef ToModel(this RekorEntryRefDto dto)
|
||||
{
|
||||
return new RekorEntryRef(
|
||||
dto.LogIndex,
|
||||
dto.LogId,
|
||||
dto.Uuid,
|
||||
dto.IntegratedTime);
|
||||
}
|
||||
|
||||
public static VerificationResult ToModel(this VerificationResultDto dto)
|
||||
{
|
||||
return new VerificationResult(
|
||||
dto.Verified,
|
||||
dto.VerifiedAt,
|
||||
dto.Verifier,
|
||||
dto.VerifierVersion,
|
||||
dto.PolicyRef,
|
||||
dto.Checks?.Select(c => c.ToModel()).ToList(),
|
||||
dto.Warnings,
|
||||
dto.Errors);
|
||||
}
|
||||
|
||||
public static VerificationCheck ToModel(this VerificationCheckDto dto)
|
||||
{
|
||||
if (!Enum.TryParse<VerificationCheckType>(dto.CheckType, ignoreCase: true, out var checkType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid check type: {dto.CheckType}");
|
||||
}
|
||||
|
||||
return new VerificationCheck(checkType, dto.Passed, dto.Details, dto.Evidence);
|
||||
}
|
||||
|
||||
public static AttestationPointerResponse ToResponse(this AttestationPointerRecord record)
|
||||
{
|
||||
return new AttestationPointerResponse(
|
||||
record.PointerId.ToString(),
|
||||
record.FindingId,
|
||||
record.AttestationType.ToString(),
|
||||
record.Relationship.ToString(),
|
||||
record.AttestationRef.ToDto(),
|
||||
record.VerificationResult?.ToDto(),
|
||||
record.CreatedAt,
|
||||
record.CreatedBy,
|
||||
record.Metadata,
|
||||
record.LedgerEventId?.ToString());
|
||||
}
|
||||
|
||||
public static AttestationRefDto ToDto(this AttestationRef model)
|
||||
{
|
||||
return new AttestationRefDto(
|
||||
model.Digest,
|
||||
model.AttestationId?.ToString(),
|
||||
model.StorageUri,
|
||||
model.PayloadType,
|
||||
model.PredicateType,
|
||||
model.SubjectDigests,
|
||||
model.SignerInfo?.ToDto(),
|
||||
model.RekorEntry?.ToDto());
|
||||
}
|
||||
|
||||
public static SignerInfoDto ToDto(this SignerInfo model)
|
||||
{
|
||||
return new SignerInfoDto(
|
||||
model.KeyId,
|
||||
model.Issuer,
|
||||
model.Subject,
|
||||
model.CertificateChain,
|
||||
model.SignedAt);
|
||||
}
|
||||
|
||||
public static RekorEntryRefDto ToDto(this RekorEntryRef model)
|
||||
{
|
||||
return new RekorEntryRefDto(
|
||||
model.LogIndex,
|
||||
model.LogId,
|
||||
model.Uuid,
|
||||
model.IntegratedTime);
|
||||
}
|
||||
|
||||
public static VerificationResultDto ToDto(this VerificationResult model)
|
||||
{
|
||||
return new VerificationResultDto(
|
||||
model.Verified,
|
||||
model.VerifiedAt,
|
||||
model.Verifier,
|
||||
model.VerifierVersion,
|
||||
model.PolicyRef,
|
||||
model.Checks?.Select(c => c.ToDto()).ToList(),
|
||||
model.Warnings,
|
||||
model.Errors);
|
||||
}
|
||||
|
||||
public static VerificationCheckDto ToDto(this VerificationCheck model)
|
||||
{
|
||||
return new VerificationCheckDto(
|
||||
model.CheckType.ToString(),
|
||||
model.Passed,
|
||||
model.Details,
|
||||
model.Evidence);
|
||||
}
|
||||
|
||||
public static AttestationSummaryResponse ToResponse(this FindingAttestationSummary summary)
|
||||
{
|
||||
return new AttestationSummaryResponse(
|
||||
summary.FindingId,
|
||||
summary.AttestationCount,
|
||||
summary.VerifiedCount,
|
||||
summary.LatestAttestation,
|
||||
summary.AttestationTypes.Select(t => t.ToString()).ToList(),
|
||||
summary.OverallVerificationStatus.ToString());
|
||||
}
|
||||
|
||||
public static AttestationPointerQuery ToQuery(this AttestationPointerSearchRequest request, string tenantId)
|
||||
{
|
||||
IReadOnlyList<AttestationType>? attestationTypes = null;
|
||||
if (request.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
attestationTypes = request.AttestationTypes
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, ignoreCase: true, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t, ignoreCase: true))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
AttestationVerificationFilter? verificationFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.VerificationStatus))
|
||||
{
|
||||
if (Enum.TryParse<AttestationVerificationFilter>(request.VerificationStatus, ignoreCase: true, out var filter))
|
||||
{
|
||||
verificationFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
return new AttestationPointerQuery(
|
||||
tenantId,
|
||||
request.FindingIds,
|
||||
attestationTypes,
|
||||
verificationFilter,
|
||||
request.CreatedAfter,
|
||||
request.CreatedBefore,
|
||||
request.SignerIdentity,
|
||||
request.PredicateType,
|
||||
request.Limit,
|
||||
request.Offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
// === Snapshot Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotRequest(
|
||||
string? Label = null,
|
||||
string? Description = null,
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
int? ExpiresInHours = null,
|
||||
IReadOnlyList<string>? IncludeEntityTypes = null,
|
||||
bool Sign = false,
|
||||
Dictionary<string, object>? Metadata = null)
|
||||
{
|
||||
public CreateSnapshotInput ToInput(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
Label: Label,
|
||||
Description: Description,
|
||||
AtTimestamp: AtTimestamp,
|
||||
AtSequence: AtSequence,
|
||||
ExpiresIn: ExpiresInHours.HasValue ? TimeSpan.FromHours(ExpiresInHours.Value) : null,
|
||||
IncludeEntityTypes: IncludeEntityTypes?.Select(ParseEntityType).ToList(),
|
||||
Sign: Sign,
|
||||
Metadata: Metadata);
|
||||
|
||||
private static EntityType ParseEntityType(string s) =>
|
||||
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotResponse(
|
||||
Guid SnapshotId,
|
||||
string? Label,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
SnapshotStatisticsResponse Statistics,
|
||||
string? MerkleRoot,
|
||||
string? DsseDigest,
|
||||
Dictionary<string, object>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Response for snapshot statistics.
|
||||
/// </summary>
|
||||
public sealed record SnapshotStatisticsResponse(
|
||||
long FindingsCount,
|
||||
long VexStatementsCount,
|
||||
long AdvisoriesCount,
|
||||
long SbomsCount,
|
||||
long EventsCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotResponse(
|
||||
bool Success,
|
||||
SnapshotResponse? Snapshot,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing snapshots.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListResponse(
|
||||
IReadOnlyList<SnapshotResponse> Snapshots,
|
||||
string? NextPageToken);
|
||||
|
||||
// === Time-Travel Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryApiRequest(
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
Guid? SnapshotId = null,
|
||||
string? Status = null,
|
||||
decimal? SeverityMin = null,
|
||||
decimal? SeverityMax = null,
|
||||
string? PolicyVersion = null,
|
||||
string? ArtifactId = null,
|
||||
string? VulnId = null,
|
||||
int PageSize = 500,
|
||||
string? PageToken = null)
|
||||
{
|
||||
public HistoricalQueryRequest ToRequest(string tenantId, EntityType entityType) => new(
|
||||
TenantId: tenantId,
|
||||
AtTimestamp: AtTimestamp,
|
||||
AtSequence: AtSequence,
|
||||
SnapshotId: SnapshotId,
|
||||
EntityType: entityType,
|
||||
Filters: new TimeQueryFilters(
|
||||
Status: Status,
|
||||
SeverityMin: SeverityMin,
|
||||
SeverityMax: SeverityMax,
|
||||
PolicyVersion: PolicyVersion,
|
||||
ArtifactId: ArtifactId,
|
||||
VulnId: VulnId),
|
||||
PageSize: PageSize,
|
||||
PageToken: PageToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryApiResponse<T>(
|
||||
QueryPointResponse QueryPoint,
|
||||
string EntityType,
|
||||
IReadOnlyList<T> Items,
|
||||
string? NextPageToken,
|
||||
long TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Query point response.
|
||||
/// </summary>
|
||||
public sealed record QueryPointResponse(
|
||||
DateTimeOffset Timestamp,
|
||||
long SequenceNumber,
|
||||
Guid? SnapshotId);
|
||||
|
||||
/// <summary>
|
||||
/// Finding history item response.
|
||||
/// </summary>
|
||||
public sealed record FindingHistoryResponse(
|
||||
string FindingId,
|
||||
string ArtifactId,
|
||||
string VulnId,
|
||||
string Status,
|
||||
decimal? Severity,
|
||||
string? PolicyVersion,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastUpdated,
|
||||
Dictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>
|
||||
/// VEX history item response.
|
||||
/// </summary>
|
||||
public sealed record VexHistoryResponse(
|
||||
string StatementId,
|
||||
string VulnId,
|
||||
string ProductId,
|
||||
string Status,
|
||||
string? Justification,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Advisory history item response.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryHistoryResponse(
|
||||
string AdvisoryId,
|
||||
string Source,
|
||||
string Title,
|
||||
decimal? CvssScore,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset? ModifiedAt);
|
||||
|
||||
// === Replay Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for replaying events.
|
||||
/// </summary>
|
||||
public sealed record ReplayApiRequest(
|
||||
long? FromSequence = null,
|
||||
long? ToSequence = null,
|
||||
DateTimeOffset? FromTimestamp = null,
|
||||
DateTimeOffset? ToTimestamp = null,
|
||||
IReadOnlyList<Guid>? ChainIds = null,
|
||||
IReadOnlyList<string>? EventTypes = null,
|
||||
bool IncludePayload = true,
|
||||
int PageSize = 1000)
|
||||
{
|
||||
public ReplayRequest ToRequest(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
FromSequence: FromSequence,
|
||||
ToSequence: ToSequence,
|
||||
FromTimestamp: FromTimestamp,
|
||||
ToTimestamp: ToTimestamp,
|
||||
ChainIds: ChainIds,
|
||||
EventTypes: EventTypes,
|
||||
IncludePayload: IncludePayload,
|
||||
PageSize: PageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayApiResponse(
|
||||
IReadOnlyList<ReplayEventResponse> Events,
|
||||
ReplayMetadataResponse Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Replay event response.
|
||||
/// </summary>
|
||||
public sealed record ReplayEventResponse(
|
||||
Guid EventId,
|
||||
long SequenceNumber,
|
||||
Guid ChainId,
|
||||
int ChainSequence,
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? ActorId,
|
||||
string? ActorType,
|
||||
string? ArtifactId,
|
||||
string? FindingId,
|
||||
string? PolicyVersion,
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
object? Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Replay metadata response.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetadataResponse(
|
||||
long FromSequence,
|
||||
long ToSequence,
|
||||
long EventsCount,
|
||||
bool HasMore,
|
||||
long ReplayDurationMs);
|
||||
|
||||
// === Diff Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for computing diff.
|
||||
/// </summary>
|
||||
public sealed record DiffApiRequest(
|
||||
DiffPointRequest From,
|
||||
DiffPointRequest To,
|
||||
IReadOnlyList<string>? EntityTypes = null,
|
||||
bool IncludeUnchanged = false,
|
||||
string OutputFormat = "Summary")
|
||||
{
|
||||
public DiffRequest ToRequest(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
From: From.ToDiffPoint(),
|
||||
To: To.ToDiffPoint(),
|
||||
EntityTypes: EntityTypes?.Select(ParseEntityType).ToList(),
|
||||
IncludeUnchanged: IncludeUnchanged,
|
||||
OutputFormat: Enum.TryParse<DiffOutputFormat>(OutputFormat, true, out var fmt)
|
||||
? fmt : DiffOutputFormat.Summary);
|
||||
|
||||
private static EntityType ParseEntityType(string s) =>
|
||||
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff point request.
|
||||
/// </summary>
|
||||
public sealed record DiffPointRequest(
|
||||
DateTimeOffset? Timestamp = null,
|
||||
long? SequenceNumber = null,
|
||||
Guid? SnapshotId = null)
|
||||
{
|
||||
public DiffPoint ToDiffPoint() => new(Timestamp, SequenceNumber, SnapshotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for diff.
|
||||
/// </summary>
|
||||
public sealed record DiffApiResponse(
|
||||
QueryPointResponse FromPoint,
|
||||
QueryPointResponse ToPoint,
|
||||
DiffSummaryResponse Summary,
|
||||
IReadOnlyList<DiffEntryResponse>? Changes,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Diff summary response.
|
||||
/// </summary>
|
||||
public sealed record DiffSummaryResponse(
|
||||
int Added,
|
||||
int Modified,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
Dictionary<string, DiffCountsResponse>? ByEntityType);
|
||||
|
||||
/// <summary>
|
||||
/// Diff counts response.
|
||||
/// </summary>
|
||||
public sealed record DiffCountsResponse(int Added, int Modified, int Removed);
|
||||
|
||||
/// <summary>
|
||||
/// Diff entry response.
|
||||
/// </summary>
|
||||
public sealed record DiffEntryResponse(
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string ChangeType,
|
||||
object? FromState,
|
||||
object? ToState,
|
||||
IReadOnlyList<string>? ChangedFields);
|
||||
|
||||
// === Changelog Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry response.
|
||||
/// </summary>
|
||||
public sealed record ChangeLogEntryResponse(
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string EventType,
|
||||
string? EventHash,
|
||||
string? ActorId,
|
||||
string? Summary);
|
||||
|
||||
// === Staleness Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Staleness check response.
|
||||
/// </summary>
|
||||
public sealed record StalenessResponse(
|
||||
bool IsStale,
|
||||
DateTimeOffset CheckedAt,
|
||||
DateTimeOffset? LastEventAt,
|
||||
string StalenessThreshold,
|
||||
string? StalenessDuration,
|
||||
Dictionary<string, EntityStalenessResponse>? ByEntityType);
|
||||
|
||||
/// <summary>
|
||||
/// Entity staleness response.
|
||||
/// </summary>
|
||||
public sealed record EntityStalenessResponse(
|
||||
bool IsStale,
|
||||
DateTimeOffset? LastEventAt,
|
||||
long EventsBehind);
|
||||
|
||||
// === Extension Methods ===
|
||||
|
||||
public static class SnapshotExtensions
|
||||
{
|
||||
public static SnapshotResponse ToResponse(this LedgerSnapshot snapshot) => new(
|
||||
SnapshotId: snapshot.SnapshotId,
|
||||
Label: snapshot.Label,
|
||||
Description: snapshot.Description,
|
||||
Status: snapshot.Status.ToString(),
|
||||
CreatedAt: snapshot.CreatedAt,
|
||||
ExpiresAt: snapshot.ExpiresAt,
|
||||
SequenceNumber: snapshot.SequenceNumber,
|
||||
Timestamp: snapshot.Timestamp,
|
||||
Statistics: snapshot.Statistics.ToResponse(),
|
||||
MerkleRoot: snapshot.MerkleRoot,
|
||||
DsseDigest: snapshot.DsseDigest,
|
||||
Metadata: snapshot.Metadata);
|
||||
|
||||
public static SnapshotStatisticsResponse ToResponse(this SnapshotStatistics stats) => new(
|
||||
FindingsCount: stats.FindingsCount,
|
||||
VexStatementsCount: stats.VexStatementsCount,
|
||||
AdvisoriesCount: stats.AdvisoriesCount,
|
||||
SbomsCount: stats.SbomsCount,
|
||||
EventsCount: stats.EventsCount,
|
||||
SizeBytes: stats.SizeBytes);
|
||||
|
||||
public static QueryPointResponse ToResponse(this QueryPoint point) => new(
|
||||
Timestamp: point.Timestamp,
|
||||
SequenceNumber: point.SequenceNumber,
|
||||
SnapshotId: point.SnapshotId);
|
||||
|
||||
public static FindingHistoryResponse ToResponse(this FindingHistoryItem item) => new(
|
||||
FindingId: item.FindingId,
|
||||
ArtifactId: item.ArtifactId,
|
||||
VulnId: item.VulnId,
|
||||
Status: item.Status,
|
||||
Severity: item.Severity,
|
||||
PolicyVersion: item.PolicyVersion,
|
||||
FirstSeen: item.FirstSeen,
|
||||
LastUpdated: item.LastUpdated,
|
||||
Labels: item.Labels);
|
||||
|
||||
public static VexHistoryResponse ToResponse(this VexHistoryItem item) => new(
|
||||
StatementId: item.StatementId,
|
||||
VulnId: item.VulnId,
|
||||
ProductId: item.ProductId,
|
||||
Status: item.Status,
|
||||
Justification: item.Justification,
|
||||
IssuedAt: item.IssuedAt,
|
||||
ExpiresAt: item.ExpiresAt);
|
||||
|
||||
public static AdvisoryHistoryResponse ToResponse(this AdvisoryHistoryItem item) => new(
|
||||
AdvisoryId: item.AdvisoryId,
|
||||
Source: item.Source,
|
||||
Title: item.Title,
|
||||
CvssScore: item.CvssScore,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ModifiedAt: item.ModifiedAt);
|
||||
|
||||
public static ReplayEventResponse ToResponse(this ReplayEvent e) => new(
|
||||
EventId: e.EventId,
|
||||
SequenceNumber: e.SequenceNumber,
|
||||
ChainId: e.ChainId,
|
||||
ChainSequence: e.ChainSequence,
|
||||
EventType: e.EventType,
|
||||
OccurredAt: e.OccurredAt,
|
||||
RecordedAt: e.RecordedAt,
|
||||
ActorId: e.ActorId,
|
||||
ActorType: e.ActorType,
|
||||
ArtifactId: e.ArtifactId,
|
||||
FindingId: e.FindingId,
|
||||
PolicyVersion: e.PolicyVersion,
|
||||
EventHash: e.EventHash,
|
||||
PreviousHash: e.PreviousHash,
|
||||
Payload: e.Payload);
|
||||
|
||||
public static ReplayMetadataResponse ToResponse(this ReplayMetadata m) => new(
|
||||
FromSequence: m.FromSequence,
|
||||
ToSequence: m.ToSequence,
|
||||
EventsCount: m.EventsCount,
|
||||
HasMore: m.HasMore,
|
||||
ReplayDurationMs: m.ReplayDurationMs);
|
||||
|
||||
public static DiffSummaryResponse ToResponse(this DiffSummary summary) => new(
|
||||
Added: summary.Added,
|
||||
Modified: summary.Modified,
|
||||
Removed: summary.Removed,
|
||||
Unchanged: summary.Unchanged,
|
||||
ByEntityType: summary.ByEntityType?.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => new DiffCountsResponse(kv.Value.Added, kv.Value.Modified, kv.Value.Removed)));
|
||||
|
||||
public static DiffEntryResponse ToResponse(this DiffEntry entry) => new(
|
||||
EntityType: entry.EntityType.ToString(),
|
||||
EntityId: entry.EntityId,
|
||||
ChangeType: entry.ChangeType.ToString(),
|
||||
FromState: entry.FromState,
|
||||
ToState: entry.ToState,
|
||||
ChangedFields: entry.ChangedFields);
|
||||
|
||||
public static ChangeLogEntryResponse ToResponse(this ChangeLogEntry entry) => new(
|
||||
SequenceNumber: entry.SequenceNumber,
|
||||
Timestamp: entry.Timestamp,
|
||||
EntityType: entry.EntityType.ToString(),
|
||||
EntityId: entry.EntityId,
|
||||
EventType: entry.EventType,
|
||||
EventHash: entry.EventHash,
|
||||
ActorId: entry.ActorId,
|
||||
Summary: entry.Summary);
|
||||
|
||||
public static StalenessResponse ToResponse(this StalenessResult result) => new(
|
||||
IsStale: result.IsStale,
|
||||
CheckedAt: result.CheckedAt,
|
||||
LastEventAt: result.LastEventAt,
|
||||
StalenessThreshold: result.StalenessThreshold.ToString(),
|
||||
StalenessDuration: result.StalenessDuration?.ToString(),
|
||||
ByEntityType: result.ByEntityType?.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => new EntityStalenessResponse(kv.Value.IsStale, kv.Value.LastEventAt, kv.Value.EventsBehind)));
|
||||
}
|
||||
@@ -155,6 +155,14 @@ builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
|
||||
builder.Services.AddHostedService<LedgerProjectionWorker>();
|
||||
builder.Services.AddSingleton<ExportQueryService>();
|
||||
builder.Services.AddSingleton<AttestationQueryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Attestation.IAttestationPointerRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresAttestationPointerRepository>();
|
||||
builder.Services.AddSingleton<AttestationPointerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ISnapshotRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresSnapshotRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -633,6 +641,206 @@ app.MapPost("/internal/ledger/airgap-import", async Task<Results<Accepted<Airgap
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
// Attestation Pointer Endpoints (LEDGER-ATTEST-73-001)
|
||||
app.MapPost("/v1/ledger/attestation-pointers", async Task<Results<Created<CreateAttestationPointerResponse>, Ok<CreateAttestationPointerResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
CreateAttestationPointerRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var input = request.ToInput(tenantId);
|
||||
var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new CreateAttestationPointerResponse(
|
||||
result.Success,
|
||||
result.PointerId?.ToString(),
|
||||
result.LedgerEventId?.ToString(),
|
||||
result.Error);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "attestation_pointer_failed",
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("CreateAttestationPointer")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task<Results<JsonHttpResult<AttestationPointerResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string pointerId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_pointer_id",
|
||||
detail: "Pointer ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (pointer is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Json(pointer.ToResponse());
|
||||
})
|
||||
.WithName("GetAttestationPointer")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task<Results<JsonHttpResult<IReadOnlyList<AttestationPointerResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
||||
IReadOnlyList<AttestationPointerResponse> responseList = pointers.Select(p => p.ToResponse()).ToList();
|
||||
return TypedResults.Json(responseList);
|
||||
})
|
||||
.WithName("GetFindingAttestationPointers")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task<Results<JsonHttpResult<AttestationSummaryResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
||||
return TypedResults.Json(summary.ToResponse());
|
||||
})
|
||||
.WithName("GetFindingAttestationSummary")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPost("/v1/ledger/attestation-pointers/search", async Task<Results<JsonHttpResult<AttestationPointerSearchResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
AttestationPointerSearchRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var query = request.ToQuery(tenantId);
|
||||
var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new AttestationPointerSearchResponse(
|
||||
pointers.Select(p => p.ToResponse()).ToList(),
|
||||
pointers.Count);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("SearchAttestationPointers")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string pointerId,
|
||||
UpdateVerificationResultRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_pointer_id",
|
||||
detail: "Pointer ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verificationResult = request.VerificationResult.ToModel();
|
||||
var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("UpdateAttestationPointerVerification")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/.well-known/openapi", () =>
|
||||
{
|
||||
var contentRoot = AppContext.BaseDirectory;
|
||||
@@ -649,6 +857,383 @@ app.MapGet("/.well-known/openapi", () =>
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
|
||||
app.MapPost("/v1/ledger/snapshots", async Task<Results<Created<CreateSnapshotResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
CreateSnapshotRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var input = request.ToInput(tenantId);
|
||||
var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new CreateSnapshotResponse(
|
||||
result.Success,
|
||||
result.Snapshot?.ToResponse(),
|
||||
result.Error);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "snapshot_creation_failed",
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response);
|
||||
})
|
||||
.WithName("CreateSnapshot")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/snapshots", async Task<Results<JsonHttpResult<SnapshotListResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var statusStr = httpContext.Request.Query["status"].ToString();
|
||||
Domain.SnapshotStatus? status = null;
|
||||
if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse<Domain.SnapshotStatus>(statusStr, true, out var parsedStatus))
|
||||
{
|
||||
status = parsedStatus;
|
||||
}
|
||||
|
||||
var query = new Domain.SnapshotListQuery(
|
||||
tenantId,
|
||||
status,
|
||||
ParseDate(httpContext.Request.Query["created_after"]),
|
||||
ParseDate(httpContext.Request.Query["created_before"]),
|
||||
ParseInt(httpContext.Request.Query["page_size"]) ?? 100,
|
||||
httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new SnapshotListResponse(
|
||||
snapshots.Select(s => s.ToResponse()).ToList(),
|
||||
nextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ListSnapshots")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task<Results<JsonHttpResult<SnapshotResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_snapshot_id",
|
||||
detail: "Snapshot ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Json(snapshot.ToResponse());
|
||||
})
|
||||
.WithName("GetSnapshot")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_snapshot_id",
|
||||
detail: "Snapshot ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
})
|
||||
.WithName("DeleteSnapshot")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Time-Travel Query Endpoints
|
||||
app.MapGet("/v1/ledger/time-travel/findings", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<FindingHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
Status: httpContext.Request.Query["status"].ToString(),
|
||||
SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]),
|
||||
SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]),
|
||||
PolicyVersion: httpContext.Request.Query["policy_version"].ToString(),
|
||||
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
|
||||
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding);
|
||||
var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<FindingHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Finding",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryFindings")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/time-travel/vex", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<VexHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex);
|
||||
var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<VexHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Vex",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryVex")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/time-travel/advisories", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<AdvisoryHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory);
|
||||
var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<AdvisoryHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Advisory",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryAdvisories")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Replay Endpoint
|
||||
app.MapPost("/v1/ledger/replay", async Task<Results<JsonHttpResult<ReplayApiResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
ReplayApiRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId);
|
||||
var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new ReplayApiResponse(
|
||||
events.Select(e => e.ToResponse()).ToList(),
|
||||
metadata.ToResponse());
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ReplayEvents")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Diff Endpoint
|
||||
app.MapPost("/v1/ledger/diff", async Task<Results<JsonHttpResult<DiffApiResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
DiffApiRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId);
|
||||
var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new DiffApiResponse(
|
||||
result.FromPoint.ToResponse(),
|
||||
result.ToPoint.ToResponse(),
|
||||
result.Summary.ToResponse(),
|
||||
result.Changes?.Select(c => c.ToResponse()).ToList(),
|
||||
result.NextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ComputeDiff")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Changelog Endpoint
|
||||
app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task<Results<JsonHttpResult<IReadOnlyList<ChangeLogEntryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string entityType,
|
||||
string entityId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<Domain.EntityType>(entityType, true, out var parsedEntityType))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_entity_type",
|
||||
detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence.");
|
||||
}
|
||||
|
||||
var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100;
|
||||
var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<ChangeLogEntryResponse> response = changelog.Select(e => e.ToResponse()).ToList();
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("GetChangelog")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Staleness Check Endpoint
|
||||
app.MapGet("/v1/ledger/staleness", async Task<Results<JsonHttpResult<StalenessResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60;
|
||||
var threshold = TimeSpan.FromMinutes(thresholdMinutes);
|
||||
|
||||
var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Json(result.ToResponse());
|
||||
})
|
||||
.WithName("CheckStaleness")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Current Point Endpoint
|
||||
app.MapGet("/v1/ledger/current-point", async Task<Results<JsonHttpResult<QueryPointResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return TypedResults.Json(point.ToResponse());
|
||||
})
|
||||
.WithName("GetCurrentPoint")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.Run();
|
||||
|
||||
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
|
||||
@@ -738,3 +1323,8 @@ static bool? ParseBool(string value)
|
||||
{
|
||||
return bool.TryParse(value, out var result) ? result : null;
|
||||
}
|
||||
|
||||
static Guid? ParseGuid(string value)
|
||||
{
|
||||
return Guid.TryParse(value, out var result) ? result : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user