work
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.PacksRegistry.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IAttestationRepository
|
||||
{
|
||||
Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default);
|
||||
Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IAuditRepository
|
||||
{
|
||||
Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface ILifecycleRepository
|
||||
{
|
||||
Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IMirrorRepository
|
||||
{
|
||||
Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IPackRepository
|
||||
{
|
||||
Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IPackSignatureVerifier
|
||||
{
|
||||
Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Contracts;
|
||||
|
||||
public interface IParityRepository
|
||||
{
|
||||
Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record AttestationRecord(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string Type,
|
||||
string Digest,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string? Notes = null);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable audit event emitted for registry actions.
|
||||
/// </summary>
|
||||
public sealed record AuditRecord(
|
||||
string? PackId,
|
||||
string TenantId,
|
||||
string Event,
|
||||
DateTimeOffset OccurredAtUtc,
|
||||
string? Actor = null,
|
||||
string? Notes = null);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record LifecycleRecord(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string State,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record MirrorSourceRecord(
|
||||
string Id,
|
||||
string TenantId,
|
||||
Uri UpstreamUri,
|
||||
bool Enabled,
|
||||
string Status,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string? Notes = null,
|
||||
DateTimeOffset? LastSuccessfulSyncUtc = null);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed class PackPolicyOptions
|
||||
{
|
||||
public bool RequireSignature { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical pack metadata stored by the registry.
|
||||
/// </summary>
|
||||
public sealed record PackRecord(
|
||||
string PackId,
|
||||
string Name,
|
||||
string Version,
|
||||
string TenantId,
|
||||
string Digest,
|
||||
string? Signature,
|
||||
string? ProvenanceUri,
|
||||
string? ProvenanceDigest,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
public sealed record ParityRecord(
|
||||
string PackId,
|
||||
string TenantId,
|
||||
string Status,
|
||||
string? Notes,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class AttestationService
|
||||
{
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IAttestationRepository _attestationRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationService(IPackRepository packRepository, IAttestationRepository attestationRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_attestationRepository = attestationRepository ?? throw new ArgumentNullException(nameof(attestationRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<AttestationRecord> UploadAsync(string packId, string tenantId, string type, byte[] content, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
|
||||
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for attestation upload.");
|
||||
}
|
||||
|
||||
var digest = ComputeSha256(content);
|
||||
var record = new AttestationRecord(packId.Trim(), tenantId.Trim(), type.Trim(), digest, _timeProvider.GetUtcNow(), notes?.Trim());
|
||||
await _attestationRepository.UpsertAsync(record, content, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "attestation.uploaded", record.CreatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
return _attestationRepository.GetAsync(packId.Trim(), type.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _attestationRepository.ListAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
||||
return _attestationRepository.GetContentAsync(packId.Trim(), type.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class ComplianceService
|
||||
{
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IParityRepository _parityRepository;
|
||||
private readonly ILifecycleRepository _lifecycleRepository;
|
||||
|
||||
public ComplianceService(IPackRepository packRepository, IParityRepository parityRepository, ILifecycleRepository lifecycleRepository)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
}
|
||||
|
||||
public async Task<ComplianceSummary> SummarizeAsync(string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var total = packs.Count;
|
||||
var unsigned = packs.Count(p => string.IsNullOrWhiteSpace(p.Signature));
|
||||
var promoted = lifecycle.Count(l => string.Equals(l.State, "promoted", StringComparison.OrdinalIgnoreCase));
|
||||
var deprecated = lifecycle.Count(l => string.Equals(l.State, "deprecated", StringComparison.OrdinalIgnoreCase));
|
||||
var parityReady = parity.Count(p => string.Equals(p.Status, "ready", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new ComplianceSummary(total, unsigned, promoted, deprecated, parityReady);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ComplianceSummary(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class ExportService
|
||||
{
|
||||
private static readonly DateTimeOffset ZipEpoch = new DateTimeOffset(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IParityRepository _parityRepository;
|
||||
private readonly ILifecycleRepository _lifecycleRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ExportService(
|
||||
IPackRepository packRepository,
|
||||
IParityRepository parityRepository,
|
||||
ILifecycleRepository lifecycleRepository,
|
||||
IAuditRepository auditRepository,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||
}
|
||||
|
||||
public async Task<MemoryStream> ExportOfflineSeedAsync(string? tenantId, bool includeContent, bool includeProvenance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = await _packRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var parity = await _parityRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var lifecycle = await _lifecycleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var audits = await _auditRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
WriteNdjson(archive, "packs.ndjson", packs.OrderBy(p => p.PackId, StringComparer.Ordinal));
|
||||
WriteNdjson(archive, "parity.ndjson", parity.OrderBy(p => p.PackId, StringComparer.Ordinal));
|
||||
WriteNdjson(archive, "lifecycle.ndjson", lifecycle.OrderBy(l => l.PackId, StringComparer.Ordinal));
|
||||
WriteNdjson(archive, "audit.ndjson", audits.OrderBy(a => a.OccurredAtUtc).ThenBy(a => a.PackId, StringComparer.Ordinal));
|
||||
|
||||
if (includeContent)
|
||||
{
|
||||
foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal))
|
||||
{
|
||||
var content = await _packRepository.GetContentAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null || content.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = archive.CreateEntry($"content/{pack.PackId}.bin", CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(content.AsMemory(0, content.Length), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeProvenance)
|
||||
{
|
||||
foreach (var pack in packs.OrderBy(p => p.PackId, StringComparer.Ordinal))
|
||||
{
|
||||
var provenance = await _packRepository.GetProvenanceAsync(pack.PackId, cancellationToken).ConfigureAwait(false);
|
||||
if (provenance is null || provenance.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = archive.CreateEntry($"provenance/{pack.PackId}.json", CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(provenance.AsMemory(0, provenance.Length), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
var auditTenant = string.IsNullOrWhiteSpace(tenantId) ? "*" : tenantId.Trim();
|
||||
await _auditRepository.AppendAsync(new AuditRecord(null, auditTenant, "offline.seed.exported", _timeProvider.GetUtcNow(), null, includeContent ? "with-content" : null), cancellationToken).ConfigureAwait(false);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private void WriteNdjson<T>(ZipArchive archive, string entryName, IEnumerable<T> records)
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
entry.LastWriteTime = ZipEpoch;
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream);
|
||||
foreach (var record in records)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(record, _jsonOptions);
|
||||
writer.WriteLine(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class LifecycleService
|
||||
{
|
||||
private readonly ILifecycleRepository _lifecycleRepository;
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly HashSet<string> AllowedStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"promoted", "deprecated", "draft"
|
||||
};
|
||||
|
||||
public LifecycleService(ILifecycleRepository lifecycleRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_lifecycleRepository = lifecycleRepository ?? throw new ArgumentNullException(nameof(lifecycleRepository));
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<LifecycleRecord> SetStateAsync(string packId, string tenantId, string state, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(state);
|
||||
|
||||
if (!AllowedStates.Contains(state))
|
||||
{
|
||||
throw new InvalidOperationException($"State '{state}' is not allowed (use: {string.Join(',', AllowedStates)}).");
|
||||
}
|
||||
|
||||
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
}
|
||||
|
||||
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for lifecycle update.");
|
||||
}
|
||||
|
||||
var record = new LifecycleRecord(packId.Trim(), tenantId.Trim(), state.Trim(), notes?.Trim(), _timeProvider.GetUtcNow());
|
||||
await _lifecycleRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "lifecycle.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _lifecycleRepository.GetAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _lifecycleRepository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class MirrorService
|
||||
{
|
||||
private readonly IMirrorRepository _mirrorRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public MirrorService(IMirrorRepository mirrorRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_mirrorRepository = mirrorRepository ?? throw new ArgumentNullException(nameof(mirrorRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<MirrorSourceRecord> UpsertAsync(string id, string tenantId, Uri upstreamUri, bool enabled, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(upstreamUri);
|
||||
|
||||
var record = new MirrorSourceRecord(id.Trim(), tenantId.Trim(), upstreamUri, enabled, enabled ? "enabled" : "disabled", _timeProvider.GetUtcNow(), notes?.Trim(), null);
|
||||
await _mirrorRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.upserted", record.UpdatedAtUtc, null, upstreamUri.ToString()), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _mirrorRepository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
|
||||
public async Task<MirrorSourceRecord?> MarkSyncAsync(string id, string tenantId, string status, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _mirrorRepository.GetAsync(id.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(existing.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for mirror sync update.");
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Status = status.Trim(),
|
||||
UpdatedAtUtc = _timeProvider.GetUtcNow(),
|
||||
LastSuccessfulSyncUtc = string.Equals(status, "synced", StringComparison.OrdinalIgnoreCase) ? _timeProvider.GetUtcNow() : existing.LastSuccessfulSyncUtc,
|
||||
Notes = notes ?? existing.Notes
|
||||
};
|
||||
|
||||
await _mirrorRepository.UpsertAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(null, tenantId, "mirror.sync", updated.UpdatedAtUtc, null, status), cancellationToken).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class PackService
|
||||
{
|
||||
private readonly IPackRepository _repository;
|
||||
private readonly IPackSignatureVerifier _signatureVerifier;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PackPolicyOptions _policy;
|
||||
|
||||
public PackService(IPackRepository repository, IPackSignatureVerifier signatureVerifier, IAuditRepository auditRepository, PackPolicyOptions? policy = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_policy = policy ?? new PackPolicyOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PackRecord> UploadAsync(
|
||||
string name,
|
||||
string version,
|
||||
string tenantId,
|
||||
byte[] content,
|
||||
string? signature,
|
||||
string? provenanceUri,
|
||||
byte[]? provenanceContent,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var digest = ComputeSha256(content);
|
||||
|
||||
if (_policy.RequireSignature && string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
throw new InvalidOperationException("Signature is required by policy.");
|
||||
}
|
||||
|
||||
var valid = await _signatureVerifier.VerifyAsync(content, digest, signature, cancellationToken).ConfigureAwait(false);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Signature validation failed for uploaded pack.");
|
||||
}
|
||||
|
||||
string? provenanceDigest = null;
|
||||
if (provenanceContent is { Length: > 0 })
|
||||
{
|
||||
provenanceDigest = ComputeSha256(provenanceContent);
|
||||
}
|
||||
|
||||
var packId = BuildPackId(name, version);
|
||||
var record = new PackRecord(
|
||||
PackId: packId,
|
||||
Name: name.Trim(),
|
||||
Version: version.Trim(),
|
||||
TenantId: tenantId.Trim(),
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
ProvenanceUri: provenanceUri,
|
||||
ProvenanceDigest: provenanceDigest,
|
||||
CreatedAtUtc: _timeProvider.GetUtcNow(),
|
||||
Metadata: metadata);
|
||||
|
||||
await _repository.UpsertAsync(record, content, provenanceContent, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "pack.uploaded", record.CreatedAtUtc, null, provenanceUri), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _repository.GetAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _repository.GetContentAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _repository.GetProvenanceAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _repository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
|
||||
public async Task<PackRecord> RotateSignatureAsync(
|
||||
string packId,
|
||||
string tenantId,
|
||||
string newSignature,
|
||||
IPackSignatureVerifier? verifierOverride = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newSignature);
|
||||
|
||||
var record = await _repository.GetAsync(packId.Trim(), cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
|
||||
if (!string.Equals(record.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for signature rotation.");
|
||||
}
|
||||
|
||||
var content = await _repository.GetContentAsync(packId.Trim(), cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Pack content missing; cannot rotate signature.");
|
||||
|
||||
var digest = ComputeSha256(content);
|
||||
var verifier = verifierOverride ?? _signatureVerifier;
|
||||
var valid = await verifier.VerifyAsync(content, digest, newSignature, cancellationToken).ConfigureAwait(false);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Signature validation failed during rotation.");
|
||||
}
|
||||
|
||||
var provenance = await _repository.GetProvenanceAsync(packId.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
var updated = record with { Signature = newSignature, Digest = digest };
|
||||
await _repository.UpsertAsync(updated, content, provenance, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "signature.rotated", _timeProvider.GetUtcNow(), null, null), cancellationToken).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string BuildPackId(string name, string version)
|
||||
{
|
||||
var cleanName = name.Trim().ToLowerInvariant().Replace(' ', '-');
|
||||
var cleanVersion = version.Trim().ToLowerInvariant();
|
||||
return $"{cleanName}@{cleanVersion}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
|
||||
namespace StellaOps.PacksRegistry.Core.Services;
|
||||
|
||||
public sealed class ParityService
|
||||
{
|
||||
private readonly IParityRepository _parityRepository;
|
||||
private readonly IPackRepository _packRepository;
|
||||
private readonly IAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ParityService(IParityRepository parityRepository, IPackRepository packRepository, IAuditRepository auditRepository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_parityRepository = parityRepository ?? throw new ArgumentNullException(nameof(parityRepository));
|
||||
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ParityRecord> SetStatusAsync(string packId, string tenantId, string status, string? notes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(status);
|
||||
|
||||
var pack = await _packRepository.GetAsync(packId, cancellationToken).ConfigureAwait(false);
|
||||
if (pack is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Pack {packId} not found.");
|
||||
}
|
||||
|
||||
if (!string.Equals(pack.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Tenant mismatch for parity update.");
|
||||
}
|
||||
|
||||
var record = new ParityRecord(packId.Trim(), tenantId.Trim(), status.Trim(), notes?.Trim(), _timeProvider.GetUtcNow());
|
||||
await _parityRepository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
await _auditRepository.AppendAsync(new AuditRecord(packId, tenantId, "parity.updated", record.UpdatedAtUtc, null, notes), cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
|
||||
return _parityRepository.GetAsync(packId.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> _parityRepository.ListAsync(tenantId?.Trim(), cancellationToken);
|
||||
}
|
||||
Reference in New Issue
Block a user