work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -1,6 +0,0 @@
namespace StellaOps.PacksRegistry.Core;
public class Class1
{
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace StellaOps.PacksRegistry.Core.Contracts;
public interface IPackSignatureVerifier
{
Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed record LifecycleRecord(
string PackId,
string TenantId,
string State,
string? Notes,
DateTimeOffset UpdatedAtUtc);

View File

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

View File

@@ -0,0 +1,7 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed class PackPolicyOptions
{
public bool RequireSignature { get; set; }
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace StellaOps.PacksRegistry.Core.Models;
public sealed record ParityRecord(
string PackId,
string TenantId,
string Status,
string? Notes,
DateTimeOffset UpdatedAtUtc);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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