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

@@ -6,3 +6,4 @@
| `CLI-AIAI-31-001` | DONE (2025-11-24) | `stella advise summarize` command implemented; CLI analyzer build & tests now pass locally. |
| `CLI-AIAI-31-002` | DONE (2025-11-24) | `stella advise explain` (conflict narrative) command implemented and tested. |
| `CLI-AIAI-31-003` | DONE (2025-11-24) | `stella advise remediate` command implemented and tested. |
| `CLI-AIAI-31-004` | DONE (2025-11-24) | `stella advise batch` supports multi-key runs, per-key outputs, summary table, and tests (`HandleAdviseBatchAsync_RunsAllAdvisories`). |

View File

@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.WebService.AirGap;
/// <summary>
/// Minimal, deterministic writer for Concelier air-gap bundles. Intended as the
/// first increment for CONCELIER-AIRGAP-56-001; produces a stable NDJSON file
/// from link-not-merge cache items without external dependencies.
/// </summary>
public sealed class AirgapBundleBuilder
{
private const string BundleFileName = "concelier-airgap.ndjson";
private const string ManifestFileName = "bundle.manifest.json";
private const string EntryTraceFileName = "bundle.entry-trace.json";
public async Task<AirgapBundleResult> BuildAsync(
IEnumerable<string> cacheItems,
string outputDirectory,
DateTimeOffset? createdUtc = null,
CancellationToken cancellationToken = default)
{
if (cacheItems is null) throw new ArgumentNullException(nameof(cacheItems));
if (string.IsNullOrWhiteSpace(outputDirectory)) throw new ArgumentException("Output directory is required", nameof(outputDirectory));
Directory.CreateDirectory(outputDirectory);
var ordered = cacheItems
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.OrderBy(item => item, StringComparer.Ordinal)
.ToArray();
var bundlePath = Path.Combine(outputDirectory, BundleFileName);
await WriteNdjsonAsync(bundlePath, ordered, cancellationToken).ConfigureAwait(false);
var bundleSha = ComputeSha256FromPath(bundlePath);
var entries = ordered
.Select((value, index) => new AirgapBundleEntry
{
LineNumber = index + 1,
Sha256 = ComputeSha256(value)
})
.ToArray();
var manifestCreated = createdUtc ?? DateTimeOffset.UnixEpoch;
var manifest = new AirgapBundleManifest
{
Items = ordered,
Entries = entries,
BundleSha256 = bundleSha,
CreatedUtc = manifestCreated,
Count = ordered.Length
};
var manifestPath = Path.Combine(outputDirectory, ManifestFileName);
await WriteManifest(manifestPath, manifest, cancellationToken).ConfigureAwait(false);
var entryTracePath = Path.Combine(outputDirectory, EntryTraceFileName);
await WriteEntryTrace(entryTracePath, entries, cancellationToken).ConfigureAwait(false);
return new AirgapBundleResult(bundlePath, manifestPath, entryTracePath, bundleSha, ordered.Length);
}
private static async Task WriteNdjsonAsync(string bundlePath, IReadOnlyList<string> orderedItems, CancellationToken cancellationToken)
{
await using var stream = new FileStream(bundlePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
foreach (var item in orderedItems)
{
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteLineAsync(item).ConfigureAwait(false);
}
}
private static async Task WriteManifest(string manifestPath, AirgapBundleManifest manifest, CancellationToken cancellationToken)
{
await using var stream = new FileStream(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var payload = System.Text.Json.JsonSerializer.Serialize(manifest, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = false
});
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task WriteEntryTrace(string entryTracePath, IReadOnlyList<AirgapBundleEntry> entries, CancellationToken cancellationToken)
{
await using var stream = new FileStream(entryTracePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var payload = System.Text.Json.JsonSerializer.Serialize(entries, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = false
});
cancellationToken.ThrowIfCancellationRequested();
await writer.WriteAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static string ComputeSha256FromPath(string path)
{
using var sha = SHA256.Create();
using var stream = File.OpenRead(path);
var hashBytes = sha.ComputeHash(stream);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string ComputeSha256(string content)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(content);
var hashBytes = sha.ComputeHash(bytes);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
public sealed record AirgapBundleResult(string BundlePath, string ManifestPath, string EntryTracePath, string Sha256, int ItemCount);
public sealed record AirgapBundleManifest
{
[JsonPropertyName("items")]
public string[] Items { get; init; } = Array.Empty<string>();
[JsonPropertyName("entries")]
public AirgapBundleEntry[] Entries { get; init; } = Array.Empty<AirgapBundleEntry>();
[JsonPropertyName("bundleSha256")]
public string BundleSha256 { get; init; } = string.Empty;
[JsonPropertyName("createdUtc")]
public DateTimeOffset CreatedUtc { get; init; }
[JsonPropertyName("count")]
public int Count { get; init; }
}
public sealed record AirgapBundleEntry
{
[JsonPropertyName("lineNumber")]
public int LineNumber { get; init; }
[JsonPropertyName("sha256")]
public string Sha256 { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.WebService.AirGap;
public sealed class AirgapBundleValidator
{
public async Task<AirgapBundleValidationResult> ValidateAsync(
string bundlePath,
string manifestPath,
string? entryTracePath = null,
CancellationToken cancellationToken = default)
{
var errors = new List<string>();
if (!File.Exists(bundlePath))
{
errors.Add($"Bundle file missing: {bundlePath}");
return new AirgapBundleValidationResult(false, errors);
}
if (!File.Exists(manifestPath))
{
errors.Add($"Manifest file missing: {manifestPath}");
return new AirgapBundleValidationResult(false, errors);
}
AirgapBundleManifest? manifest = null;
try
{
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
manifest = JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson);
}
catch (Exception ex)
{
errors.Add($"Manifest parse error: {ex.Message}");
}
var lines = await File.ReadAllLinesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
var bundleSha = ComputeSha256FromFile(bundlePath);
if (manifest is null)
{
return new AirgapBundleValidationResult(false, errors);
}
if (!string.Equals(bundleSha, manifest.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
errors.Add("Bundle hash mismatch");
}
if (manifest.Count != lines.Length)
{
errors.Add($"Manifest count {manifest.Count} != bundle lines {lines.Length}");
}
var ordered = lines.ToArray();
if (!manifest.Items.SequenceEqual(ordered))
{
errors.Add("Manifest items differ from bundle payload");
}
// If entry trace exists (either provided or embedded in manifest), verify per-line hashes.
AirgapBundleEntry[] entries = manifest.Entries ?? Array.Empty<AirgapBundleEntry>();
if (!string.IsNullOrWhiteSpace(entryTracePath) && File.Exists(entryTracePath))
{
try
{
var traceJson = await File.ReadAllTextAsync(entryTracePath!, cancellationToken).ConfigureAwait(false);
var traceEntries = JsonSerializer.Deserialize<AirgapBundleEntry[]>(traceJson);
if (traceEntries is not null)
{
entries = traceEntries;
}
}
catch (Exception ex)
{
errors.Add($"Entry trace parse error: {ex.Message}");
}
}
if (entries.Length > 0)
{
if (entries.Length != lines.Length)
{
errors.Add($"Entry trace length {entries.Length} != bundle lines {lines.Length}");
}
else
{
for (var i = 0; i < lines.Length; i++)
{
var expectedHash = ComputeSha256(lines[i]);
if (!string.Equals(entries[i].Sha256, expectedHash, StringComparison.OrdinalIgnoreCase))
{
errors.Add($"Entry trace hash mismatch at line {i + 1}");
break;
}
}
}
}
return new AirgapBundleValidationResult(errors.Count == 0, errors);
}
private static string ComputeSha256(string content)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(content);
var hashBytes = sha.ComputeHash(bytes);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string ComputeSha256FromFile(string path)
{
using var sha = SHA256.Create();
using var stream = File.OpenRead(path);
var hashBytes = sha.ComputeHash(stream);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
public sealed record AirgapBundleValidationResult(bool IsValid, IReadOnlyList<string> Errors);

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Concelier.WebService;
public sealed record VerifyAttestationRequest(
string? BundlePath,
string? ManifestPath,
string? TransparencyPath,
string? PipelineVersion);
public readonly record struct EvidencePathResolutionResult(
bool IsValid,
string? BundlePath,
string? ManifestPath,
string? TransparencyPath,
string? Error,
string? ErrorDetails)
{
public static EvidencePathResolutionResult Valid(string bundlePath, string manifestPath, string? transparencyPath) =>
new(true, bundlePath, manifestPath, transparencyPath, null, null);
public static EvidencePathResolutionResult Invalid(string error, string? details = null) =>
new(false, null, null, null, error, details);
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record EvidenceBatchRequest(
IReadOnlyCollection<EvidenceBatchItemRequest> Items,
int? ObservationLimit,
int? LinksetLimit);
public sealed record EvidenceBatchItemRequest(
string? ComponentId,
IReadOnlyCollection<string>? Purls,
IReadOnlyCollection<string>? Aliases);
public sealed record EvidenceBatchItemResponse(
string ComponentId,
IReadOnlyCollection<AdvisoryObservation> Observations,
IReadOnlyCollection<AdvisoryLinkset> Linksets,
bool HasMore,
DateTimeOffset RetrievedAt);
public sealed record EvidenceBatchResponse(
IReadOnlyCollection<EvidenceBatchItemResponse> Items);

View File

@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService;
public sealed record EvidenceSnapshotResponse(
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("manifestPath")] string ManifestPath,
[property: JsonPropertyName("manifestHash")] string ManifestHash,
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);
public sealed record AttestationStatusResponse(
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("claims")] AttestationClaims Claims,
[property: JsonPropertyName("bundlePath")] string BundlePath,
[property: JsonPropertyName("manifestPath")] string ManifestPath,
[property: JsonPropertyName("transparencyPath")] string? TransparencyPath,
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion);

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService;
public sealed record IncidentUpsertRequest(
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("cooldownMinutes")] int? CooldownMinutes);
public sealed record IncidentStatusResponse(
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
[property: JsonPropertyName("tenant")] string Tenant,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("activatedAt")] string ActivatedAt,
[property: JsonPropertyName("cooldownUntil")] string CooldownUntil,
[property: JsonPropertyName("pipelineVersion")] string? PipelineVersion,
[property: JsonPropertyName("active")] bool Active);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
using System.Text.Json;
namespace StellaOps.Concelier.WebService.Services;
internal static class IncidentFileStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public static string GetIncidentFilePath(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey)
{
ArgumentNullException.ThrowIfNull(evidenceOptions);
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(advisoryKey);
var root = evidenceOptions.RootAbsolute ?? evidenceOptions.Root ?? string.Empty;
return Path.Combine(root, tenant.Trim(), advisoryKey.Trim(), "incident.json");
}
public static async Task WriteAsync(
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
string tenant,
string advisoryKey,
string reason,
int cooldownMinutes,
string? pipelineVersion,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var activatedAt = now.ToUniversalTime();
var cooldownUntil = activatedAt.AddMinutes(cooldownMinutes);
var payload = new IncidentFile
{
AdvisoryKey = advisoryKey.Trim(),
Tenant = tenant.Trim(),
Reason = string.IsNullOrWhiteSpace(reason) ? "unspecified" : reason.Trim(),
ActivatedAt = activatedAt,
CooldownUntil = cooldownUntil,
PipelineVersion = pipelineVersion,
};
var json = JsonSerializer.Serialize(payload, SerializerOptions);
await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
}
public static async Task<IncidentStatusResponse?> ReadAsync(
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
string tenant,
string advisoryKey,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
if (!File.Exists(path))
{
return null;
}
await using var stream = File.OpenRead(path);
var payload = await JsonSerializer.DeserializeAsync<IncidentFile>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
if (payload is null)
{
return null;
}
var active = payload.CooldownUntil > now.ToUniversalTime();
return new IncidentStatusResponse(
payload.AdvisoryKey,
payload.Tenant,
payload.Reason,
payload.ActivatedAt.ToUniversalTime().ToString("O"),
payload.CooldownUntil.ToUniversalTime().ToString("O"),
payload.PipelineVersion,
active);
}
public static Task DeleteAsync(ConcelierOptions.EvidenceBundleOptions evidenceOptions, string tenant, string advisoryKey, CancellationToken cancellationToken)
{
var path = GetIncidentFilePath(evidenceOptions, tenant, advisoryKey);
if (File.Exists(path))
{
File.Delete(path);
}
return Task.CompletedTask;
}
private sealed record IncidentFile
{
public string AdvisoryKey { get; init; } = string.Empty;
public string Tenant { get; init; } = string.Empty;
public string Reason { get; init; } = "unspecified";
public DateTimeOffset ActivatedAt { get; init; }
public DateTimeOffset CooldownUntil { get; init; }
public string? PipelineVersion { get; init; }
}
}

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Documents;
namespace StellaOps.Concelier.Connector.Cccs.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Normalization.SemVer;
namespace StellaOps.Concelier.Connector.Cccs.Internal;
internal static class CccsMapper
{
@@ -108,44 +110,149 @@ internal static class CccsMapper
.ToArray();
}
private static IReadOnlyList<AffectedPackage> BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt)
{
if (dto.Products.Count == 0)
{
return Array.Empty<AffectedPackage>();
}
var packages = new List<AffectedPackage>(dto.Products.Count);
foreach (var product in dto.Products)
{
if (string.IsNullOrWhiteSpace(product))
{
continue;
}
var identifier = product.Trim();
var provenance = new AdvisoryProvenance(
CccsConnectorPlugin.SourceName,
"package",
identifier,
recordedAt,
new[] { ProvenanceFieldMasks.AffectedPackages });
packages.Add(new AffectedPackage(
AffectedPackageTypes.Vendor,
identifier,
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { provenance },
normalizedVersions: Array.Empty<NormalizedVersionRule>()));
}
return packages.Count == 0
? Array.Empty<AffectedPackage>()
: packages
.DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
private static IReadOnlyList<AffectedPackage> BuildPackages(CccsAdvisoryDto dto, DateTimeOffset recordedAt)
{
if (dto.Products.Count == 0)
{
return Array.Empty<AffectedPackage>();
}
var packages = new List<AffectedPackage>(dto.Products.Count);
for (var index = 0; index < dto.Products.Count; index++)
{
var product = dto.Products[index];
if (string.IsNullOrWhiteSpace(product))
{
continue;
}
var identifier = product.Trim();
var provenance = new AdvisoryProvenance(
CccsConnectorPlugin.SourceName,
"package",
identifier,
recordedAt,
new[] { ProvenanceFieldMasks.AffectedPackages });
var rangeAnchor = $"cccs:{dto.SerialNumber}:{index}";
var versionRanges = BuildVersionRanges(product, rangeAnchor, recordedAt);
var normalizedVersions = BuildNormalizedVersions(versionRanges, rangeAnchor);
packages.Add(new AffectedPackage(
AffectedPackageTypes.Vendor,
identifier,
platform: null,
versionRanges: versionRanges,
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { provenance },
normalizedVersions: normalizedVersions));
}
return packages.Count == 0
? Array.Empty<AffectedPackage>()
: packages
.DistinctBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
.OrderBy(static package => package.Identifier, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(string productText, string rangeAnchor, DateTimeOffset recordedAt)
{
var versionText = ExtractFirstVersionToken(productText);
if (string.IsNullOrWhiteSpace(versionText))
{
return Array.Empty<AffectedVersionRange>();
}
var provenance = new AdvisoryProvenance(
CccsConnectorPlugin.SourceName,
"range",
rangeAnchor,
recordedAt,
new[] { ProvenanceFieldMasks.VersionRanges });
var vendorExtensions = new Dictionary<string, string>
{
["cccs.version.raw"] = versionText!,
["cccs.anchor"] = rangeAnchor,
};
var semVerResults = SemVerRangeRuleBuilder.Build(versionText!, patchedVersion: null, provenanceNote: rangeAnchor);
if (semVerResults.Count > 0)
{
return semVerResults.Select(result =>
new AffectedVersionRange(
rangeKind: NormalizedVersionSchemes.SemVer,
introducedVersion: result.Primitive.Introduced,
fixedVersion: result.Primitive.Fixed,
lastAffectedVersion: result.Primitive.LastAffected,
rangeExpression: result.Expression ?? versionText!,
provenance: provenance,
primitives: new RangePrimitives(
result.Primitive,
Nevra: null,
Evr: null,
VendorExtensions: vendorExtensions)))
.ToArray();
}
var primitives = new RangePrimitives(
new SemVerPrimitive(
Introduced: versionText,
IntroducedInclusive: true,
Fixed: null,
FixedInclusive: false,
LastAffected: null,
LastAffectedInclusive: true,
ConstraintExpression: null,
ExactValue: versionText),
Nevra: null,
Evr: null,
VendorExtensions: vendorExtensions);
return new[]
{
new AffectedVersionRange(
rangeKind: NormalizedVersionSchemes.SemVer,
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: versionText,
provenance: provenance,
primitives: primitives),
};
}
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
IReadOnlyList<AffectedVersionRange> ranges,
string rangeAnchor)
{
if (ranges.Count == 0)
{
return Array.Empty<NormalizedVersionRule>();
}
var rules = new List<NormalizedVersionRule>(ranges.Count);
foreach (var range in ranges)
{
var rule = range.ToNormalizedVersionRule(rangeAnchor);
if (rule is not null)
{
rules.Add(rule);
}
}
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules.ToArray();
}
private static string? ExtractFirstVersionToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var match = Regex.Match(value, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?");
return match.Success ? match.Value : null;
}
}

View File

@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Normalization.SemVer;
namespace StellaOps.Concelier.Connector.CertBund.Internal;
@@ -116,23 +118,9 @@ internal static class CertBundMapper
recordedAt,
new[] { ProvenanceFieldMasks.AffectedPackages });
var ranges = string.IsNullOrWhiteSpace(product.Versions)
? Array.Empty<AffectedVersionRange>()
: new[]
{
new AffectedVersionRange(
rangeKind: "string",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: product.Versions,
provenance: new AdvisoryProvenance(
CertBundConnectorPlugin.SourceName,
"package-range",
product.Versions,
recordedAt,
new[] { ProvenanceFieldMasks.VersionRanges }))
};
var anchor = $"certbund:{dto.AdvisoryId}:{vendor.ToLowerInvariant().Replace(' ', '-')}";
var ranges = BuildVersionRanges(product.Versions, anchor, recordedAt);
var normalized = BuildNormalizedVersions(ranges, anchor);
packages.Add(new AffectedPackage(
AffectedPackageTypes.Vendor,
@@ -141,7 +129,7 @@ internal static class CertBundMapper
versionRanges: ranges,
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { provenance },
normalizedVersions: Array.Empty<NormalizedVersionRule>()));
normalizedVersions: normalized));
}
return packages
@@ -150,6 +138,87 @@ internal static class CertBundMapper
.ToArray();
}
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(string? versions, string anchor, DateTimeOffset recordedAt)
{
if (string.IsNullOrWhiteSpace(versions)
|| string.Equals(versions.Trim(), "alle", StringComparison.OrdinalIgnoreCase))
{
return Array.Empty<AffectedVersionRange>();
}
var tokens = Regex.Matches(versions, @"\d+(?:\.\d+){0,3}(?:[A-Za-z0-9\-_]*)?")
.Select(match => match.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
if (tokens.Count == 0)
{
return Array.Empty<AffectedVersionRange>();
}
var introduced = tokens.First();
var fixedVersion = tokens.Count > 1 ? tokens.Last() : null;
var vendorExtensions = new Dictionary<string, string>
{
["certbund.version.raw"] = versions!,
["certbund.anchor"] = anchor,
};
var semVer = new SemVerPrimitive(
Introduced: introduced,
IntroducedInclusive: true,
Fixed: fixedVersion,
FixedInclusive: true,
LastAffected: null,
LastAffectedInclusive: true,
ConstraintExpression: null,
ExactValue: tokens.Count == 1 ? introduced : null);
var rangeProvenance = new AdvisoryProvenance(
CertBundConnectorPlugin.SourceName,
"package-range",
anchor,
recordedAt,
new[] { ProvenanceFieldMasks.VersionRanges });
var primitives = new RangePrimitives(semVer, Nevra: null, Evr: null, VendorExtensions: vendorExtensions);
return new[]
{
new AffectedVersionRange(
rangeKind: NormalizedVersionSchemes.SemVer,
introducedVersion: introduced,
fixedVersion: fixedVersion,
lastAffectedVersion: null,
rangeExpression: versions!,
provenance: rangeProvenance,
primitives: primitives),
};
}
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
IReadOnlyList<AffectedVersionRange> ranges,
string anchor)
{
if (ranges.Count == 0)
{
return Array.Empty<NormalizedVersionRule>();
}
var rules = new List<NormalizedVersionRule>(ranges.Count);
foreach (var range in ranges)
{
var rule = range.ToNormalizedVersionRule(anchor);
if (rule is not null)
{
rules.Add(rule);
}
}
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules.ToArray();
}
private static string? MapSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))

View File

@@ -1,10 +1,11 @@
using System;
using FluentAssertions;
using StellaOps.Concelier.Connector.Cccs.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Html;
using StellaOps.Concelier.Storage.Mongo.Documents;
using Xunit;
using System;
using FluentAssertions;
using StellaOps.Concelier.Connector.Cccs.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Html;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Documents;
using Xunit;
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
@@ -35,9 +36,17 @@ public sealed class CccsMapperTests
advisory.AdvisoryKey.Should().Be("TEST-001");
advisory.Title.Should().Be(dto.Title);
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
advisory.AffectedPackages.Should().HaveCount(2);
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
}
}
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
advisory.AffectedPackages.Should().HaveCount(2);
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
var first = advisory.AffectedPackages[0];
first.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "1.0");
first.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:0" && rule.Value == "1.0");
var second = advisory.AffectedPackages[1];
second.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "2.0");
second.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:1" && rule.Value == "2.0");
}
}

View File

@@ -12,13 +12,14 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.CertBund.Configuration;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Concelier.Testing;
using Xunit;
@@ -57,16 +58,27 @@ public sealed class CertBundConnectorTests : IAsyncLifetime
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
advisory.Aliases.Should().Contain("CVE-2025-1234");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
advisory.Language.Should().Be("de");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
advisory.Aliases.Should().Contain("CVE-2025-1234");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
advisory.Language.Should().Be("de");
var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud"))
.Subject;
endpoint.VersionRanges.Should().ContainSingle(range =>
range.RangeKind == NormalizedVersionSchemes.SemVer &&
range.IntroducedVersion == "2023.1" &&
range.FixedVersion == "2024.2");
endpoint.NormalizedVersions.Should().ContainSingle(rule =>
rule.Min == "2023.1" &&
rule.Max == "2024.2" &&
rule.Notes == "certbund:WID-SEC-2025-2264:ivanti");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();

View File

@@ -8,6 +8,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
[Fact]
[Trait("Category", "Attestation")]
public async Task BuildAsync_ProducesClaimsFromSampleBundle()
{
var sampleDir = Path.Combine(RepoRoot, "docs", "samples", "evidence-bundle");
@@ -22,7 +23,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
tarPath,
manifestPath,
transparencyPath,
pipelineVersion: "git:test-sha"),
"git:test-sha"),
CancellationToken.None);
Assert.Equal("evidence-bundle-m0", claims.SubjectName);
@@ -38,6 +39,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
}
[Fact]
[Trait("Category", "Attestation")]
public async Task BuildAsync_EnforcesLowercaseTenant()
{
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
@@ -64,4 +66,30 @@ public sealed class EvidenceBundleAttestationBuilderTests
Assert.Contains("Tenant must be lowercase", ex.Message);
}
[Fact]
[Trait("Category", "Attestation")]
public async Task BuildAsync_RequiresTenant()
{
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
var manifest = """
{
"bundle_id": "test-bundle",
"version": "1.0.0",
"created": "2025-11-19T00:00:00Z",
"scope": "vex"
}
""";
await File.WriteAllTextAsync(tempManifest, manifest);
var tempTar = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.tar.gz");
await File.WriteAllTextAsync(tempTar, "dummy");
var builder = new EvidenceBundleAttestationBuilder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None));
Assert.Contains("Tenant must be present", ex.Message);
}
}

View File

@@ -0,0 +1,23 @@
using System.IO;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Attestation;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Attestation;
public class EvidenceBundleAttestationValidator
{
[Fact]
public async Task BuildAsync_RejectsMissingTenant()
{
var bundle = Path.GetTempFileName();
var manifest = Path.GetTempFileName();
await File.WriteAllTextAsync(bundle, "dummy");
await File.WriteAllTextAsync(manifest, "{\"tenant\":\"ACME\"}");
var builder = new EvidenceBundleAttestationBuilder();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
builder.BuildAsync(new EvidenceBundleAttestationRequest(bundle, manifest, null, "git:test")));
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Concelier.WebService.AirGap;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests.AirGap;
public class AirgapBundleBuilderTests
{
[Fact]
public async Task BuildAsync_WritesDeterministicNdjson()
{
var builder = new AirgapBundleBuilder();
var created = DateTimeOffset.Parse("2025-11-01T00:00:00Z");
var items = new[]
{
"b:2",
"a:1",
"c:3",
"a:1" // duplicate should still appear twice to preserve raw cache content
};
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-test");
try
{
var result = await builder.BuildAsync(items, tempDir.FullName, created);
var lines = await File.ReadAllLinesAsync(result.BundlePath);
Assert.Equal(4, lines.Length);
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, lines);
Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
Assert.Equal(4, result.ItemCount);
Assert.True(File.Exists(result.ManifestPath));
var manifestJson = await File.ReadAllTextAsync(result.ManifestPath);
var manifest = System.Text.Json.JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson)!;
Assert.Equal(result.Sha256, manifest.BundleSha256);
Assert.Equal(4, manifest.Count);
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, manifest.Items);
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }.Select(v => v.GetDeterministicHash()), manifest.Entries.Select(e => e.Sha256));
Assert.Equal(created, manifest.CreatedUtc);
var manifestJsonFirstRun = manifestJson;
var entryTraceJsonFirstRun = await File.ReadAllTextAsync(result.EntryTracePath);
// Second run should produce identical hash
var result2 = await builder.BuildAsync(items, tempDir.FullName, created);
Assert.Equal(result.Sha256, result2.Sha256);
Assert.Equal(result.ManifestPath, result2.ManifestPath); // paths stable in same directory
var manifestJsonSecondRun = await File.ReadAllTextAsync(result2.ManifestPath);
var entryTraceJsonSecondRun = await File.ReadAllTextAsync(result2.EntryTracePath);
Assert.Equal(manifestJsonFirstRun, manifestJsonSecondRun);
Assert.Equal(entryTraceJsonFirstRun, entryTraceJsonSecondRun);
}
finally
{
tempDir.Delete(recursive: true);
}
}
}
internal static class HashTestExtensions
{
public static string GetDeterministicHash(this string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
return System.Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,60 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Concelier.WebService.AirGap;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests.AirGap;
public class AirgapBundleValidatorTests
{
[Fact]
public async Task ValidateAsync_Succeeds_ForBuilderOutput()
{
var builder = new AirgapBundleBuilder();
var validator = new AirgapBundleValidator();
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator");
try
{
var items = new[] { "b:2", "a:1" };
var result = await builder.BuildAsync(items, tempDir.FullName);
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
Assert.True(validation.IsValid, string.Join(";", validation.Errors));
}
finally
{
tempDir.Delete(recursive: true);
}
}
[Fact]
public async Task ValidateAsync_Fails_WhenManifestTampered()
{
var builder = new AirgapBundleBuilder();
var validator = new AirgapBundleValidator();
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator-bad");
try
{
var items = new[] { "b:2", "a:1" };
var result = await builder.BuildAsync(items, tempDir.FullName);
// Tamper manifest count
var manifest = await File.ReadAllTextAsync(result.ManifestPath);
manifest = manifest.Replace("\"count\":2", "\"count\":3");
await File.WriteAllTextAsync(result.ManifestPath, manifest);
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
Assert.False(validation.IsValid);
Assert.Contains(validation.Errors, e => e.Contains("count", System.StringComparison.OrdinalIgnoreCase));
}
finally
{
tempDir.Delete(recursive: true);
}
}
}

View File

@@ -1,20 +1,99 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests;
public class ConcelierHealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
{
private readonly WebApplicationFactory<Program> _factory;
public ConcelierHealthEndpointTests(WebApplicationFactory<Program> factory)
public HealthWebAppFactory()
{
_factory = factory.WithWebHostBuilder(_ => { });
// Ensure options binder sees required storage values before Program.Main executes.
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DRIVER", "mongo");
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "mongodb://localhost:27017/test-health");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
var overrides = new Dictionary<string, string?>
{
{"Storage:Dsn", "mongodb://localhost:27017/test-health"},
{"Storage:Driver", "mongo"},
{"Storage:CommandTimeoutSeconds", "30"},
{"Telemetry:Enabled", "false"}
};
config.AddInMemoryCollection(overrides);
});
builder.UseSetting("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
builder.UseSetting("CONCELIER__STORAGE__DRIVER", "mongo");
builder.UseSetting("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
builder.UseSetting("CONCELIER__TELEMETRY__ENABLED", "false");
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
{
Storage = new ConcelierOptions.StorageOptions
{
Dsn = "mongodb://localhost:27017/test-health",
Driver = "mongo",
CommandTimeoutSeconds = 30
},
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
}
});
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(sp => new ConfigureOptions<ConcelierOptions>(opts =>
{
opts.Storage ??= new ConcelierOptions.StorageOptions();
opts.Storage.Driver = "mongo";
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
opts.Storage.CommandTimeoutSeconds = 30;
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
}));
services.PostConfigure<ConcelierOptions>(opts =>
{
opts.Storage ??= new ConcelierOptions.StorageOptions();
opts.Storage.Driver = "mongo";
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
opts.Storage.CommandTimeoutSeconds = 30;
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
});
});
}
}
public class ConcelierHealthEndpointTests : IClassFixture<HealthWebAppFactory>
{
private readonly HealthWebAppFactory _factory;
public ConcelierHealthEndpointTests(HealthWebAppFactory factory) => _factory = factory;
[Fact]
public async Task Health_requires_tenant_header()
{

View File

@@ -0,0 +1,43 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Concelier.WebService.Services;
using StellaOps.Concelier.WebService;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests.Services;
public sealed class IncidentFileStoreTests
{
[Fact]
public async Task WriteReadDelete_RoundTripsIncident()
{
var temp = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "incident-store-tests", Path.GetRandomFileName()));
var options = new ConcelierOptions
{
Evidence = new ConcelierOptions.EvidenceBundleOptions
{
RootAbsolute = temp.FullName,
DefaultManifestFileName = "manifest.json",
DefaultTransparencyFileName = "transparency.json",
PipelineVersion = "git:test",
},
};
var now = new DateTimeOffset(2025, 11, 25, 12, 0, 0, TimeSpan.Zero);
await IncidentFileStore.WriteAsync(options.Evidence!, "tenant-a", "ADV-1", "test-reason", 30, options.Evidence!.PipelineVersion, now, CancellationToken.None);
var status = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
status.Should().NotBeNull();
status!.Reason.Should().Be("test-reason");
status.Active.Should().BeTrue();
status.Tenant.Should().Be("tenant-a");
status.AdvisoryKey.Should().Be("ADV-1");
status.PipelineVersion.Should().Be("git:test");
await IncidentFileStore.DeleteAsync(options.Evidence!, "tenant-a", "ADV-1", CancellationToken.None);
var afterDelete = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
afterDelete.Should().BeNull();
}
}

View File

@@ -12,6 +12,7 @@
<CopyOutputSymbolsToOutputDirectory>true</CopyOutputSymbolsToOutputDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />

View File

@@ -27,6 +27,8 @@ using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Attestation;
using static StellaOps.Concelier.WebService.Program;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Models;
@@ -39,6 +41,7 @@ using StellaOps.Concelier.Core.Raw;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Contracts;
using StellaOps.Concelier.WebService;
using Xunit.Sdk;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
@@ -73,7 +76,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
public Task InitializeAsync()
{
PrepareMongoEnvironment();
if (TryStartExternalMongo(out var externalConnectionString))
if (TryStartExternalMongo(out var externalConnectionString) && !string.IsNullOrWhiteSpace(externalConnectionString))
{
_factory = new ConcelierApplicationFactory(externalConnectionString);
}
@@ -381,6 +384,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString());
Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime());
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
detailResponse.EnsureSuccessStatusCode();
@@ -390,6 +395,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime());
}
[Fact]
@@ -713,6 +719,66 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
}
[Fact]
[Trait("Category", "Attestation")]
public async Task InternalAttestationVerify_ReturnsClaims()
{
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
var manifestPath = Path.Combine(sampleDir, "manifest.json");
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
using var scope = _factory.Services.CreateScope();
var concOptions = scope.ServiceProvider.GetRequiredService<IOptions<ConcelierOptions>>().Value;
_output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}");
Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase);
using var client = _factory.CreateClient();
var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha");
var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
var claims = JsonSerializer.Deserialize<AttestationClaims>(
responseBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(claims);
Assert.Equal("evidence-bundle-m0", claims!.SubjectName);
Assert.Equal("git:test-sha", claims.PipelineVersion);
Assert.Equal(tarPath, claims.EvidenceBundlePath);
}
[Fact]
public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add(TenantHeaderName, "demo");
var request = new EvidenceBatchRequest(
new[]
{
new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" })
},
ObservationLimit: 5,
LinksetLimit: 5);
var response = await client.PostAsJsonAsync("/v1/evidence/batch", request);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<EvidenceBatchResponse>();
Assert.NotNull(payload);
var item = Assert.Single(payload!.Items);
Assert.Equal("component-a", item.ComponentId);
Assert.Empty(item.Observations);
Assert.Empty(item.Linksets);
Assert.False(item.HasMore);
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
{
@@ -1300,7 +1366,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
Assert.NotNull(payload);
var conflict = Assert.Single(payload!.Conflicts);
var conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null");
var conflict = Assert.Single(conflicts);
Assert.Equal(conflictId, conflict.ConflictId);
Assert.Equal("severity", conflict.Explainer.Type);
Assert.Equal("mismatch", conflict.Explainer.Reason);
@@ -1977,6 +2044,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
var opensslPath = ResolveOpenSsl11Path();
if (!string.IsNullOrEmpty(opensslPath))
{
var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
var merged = string.IsNullOrWhiteSpace(currentLd)
? opensslPath
: string.Join(':', opensslPath, currentLd);
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
}
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString);
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo");
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
@@ -1984,6 +2062,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot);
const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET";
if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey))
{
@@ -2002,6 +2084,23 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
}
}
private static string? ResolveOpenSsl11Path()
{
var current = AppContext.BaseDirectory;
for (var i = 0; i < 8; i++)
{
var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64"));
if (Directory.Exists(candidate))
{
return candidate;
}
current = Path.GetFullPath(Path.Combine(current, ".."));
}
return null;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
@@ -2035,7 +2134,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
options.Telemetry.EnableMetrics = false;
options.Authority ??= new ConcelierOptions.AuthorityOptions();
_authorityConfigure?.Invoke(options.Authority);
// Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400.
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
options.Evidence.Root = repoRoot;
options.Evidence.RootAbsolute = repoRoot;
});
// Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present.
var contentRoot = AppContext.BaseDirectory;
var wwwroot = Path.Combine(contentRoot, "wwwroot");
Directory.CreateDirectory(wwwroot);
});
builder.ConfigureTestServices(services =>
@@ -3093,4 +3202,5 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(map);
}
}
}

44
src/Excititor/AGENTS.md Normal file
View File

@@ -0,0 +1,44 @@
# Excititor · AGENTS Charter (Air-Gap & Trust Connectors)
## Module Scope & Working Directory
- Working directory: `src/Excititor/**` (WebService, Worker, __Libraries, __Tests, connectors, scripts). No cross-module edits unless explicitly noted in sprint Decisions & Risks.
- Mission (current sprint): air-gap parity for evidence chunks, trust connector wiring, and attestation verification aligned to Evidence Locker contract.
## Roles
- **Backend engineer (ASP.NET Core / Mongo):** chunk ingestion/export, attestation verifier, trust connector.
- **Air-Gap/Platform engineer:** sealed-mode switches, offline bundles, deterministic cache/path handling.
- **QA automation:** WebApplicationFactory + Mongo2Go tests for chunk APIs, attestations, and trust connector; deterministic ordering/hashes.
- **Docs/Schema steward:** keep chunk API, attestation plan, and trust connector docs in sync with behavior; update schemas and samples.
## Required Reading (treat as read before DOING)
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/excititor/architecture.md`
- `docs/modules/excititor/attestation-plan.md`
- `docs/modules/excititor/operations/chunk-api-user-guide.md`
- `docs/modules/excititor/schemas/vex-chunk-api.yaml`
- `docs/modules/evidence-locker/attestation-contract.md`
## Working Agreements
- Determinism: canonical JSON ordering; stable pagination; UTC ISO-8601 timestamps; sort chunk edges deterministically.
- Offline-first: default sealed-mode must not reach external networks; connectors obey allowlist; feature flags default safe.
- Attestation: DSSE/Envelope per contract; always include tenant/source identifiers; validation fixtures required.
- Tenant safety: enforce tenant headers/guards on every API; no cross-tenant leakage.
- Logging/metrics: structured logs; meters under `StellaOps.Excititor.*`; tag `tenant`, `source`, `result`.
- Cross-module edits: require sprint note; otherwise, stay within Excititor working dir.
## Testing Rules
- Use Mongo2Go/in-memory fixtures; avoid network.
- API tests in `StellaOps.Excititor.WebService.Tests`; worker/connectors in `StellaOps.Excititor.Worker.Tests`; shared fixtures in `__Tests`.
- Tests must assert determinism (ordering/hashes), tenant enforcement, and sealed-mode behavior.
## Delivery Discipline
- Update sprint tracker status (`TODO → DOING → DONE/BLOCKED`) for each task; mirror changes in Execution Log and Decisions & Risks.
- When changing contracts (API/attestation schemas), update docs and samples and link from sprint Decisions & Risks.
- If a decision is needed, mark the task BLOCKED and record the decision ask—do not pause work.
## Tooling/Env Notes
- .NET 10 with preview features enabled; Mongo driver ≥ 3.x.
- Signing/verifier hooks rely on Evidence Locker contract fixtures under `docs/modules/evidence-locker/`.
- Sealed-mode tests should run with `EXCITITOR_SEALED=1` (env var) to enforce offline code paths.

View File

@@ -21,6 +21,9 @@ public sealed class AirgapImportRequest
[JsonPropertyName("publisher")]
public string? Publisher { get; init; }
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
[JsonPropertyName("payloadHash")]
public string? PayloadHash { get; init; }

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace StellaOps.Excititor.WebService.Options;
internal sealed class AirgapOptions
{
public const string SectionName = "Excititor:Airgap";
/// <summary>
/// Enables sealed-mode enforcement for air-gapped imports.
/// When true, external payload URLs are rejected and publisher allowlist is applied.
/// </summary>
public bool SealedMode { get; set; } = false;
/// <summary>
/// When true, imports must originate from mirror/offline sources (no HTTP/HTTPS URLs).
/// </summary>
public bool MirrorOnly { get; set; } = true;
/// <summary>
/// Optional allowlist of publishers that may submit bundles while sealed mode is enabled.
/// Empty list means allow all.
/// </summary>
public List<string> TrustedPublishers { get; } = new();
}

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Collections.Immutable;
using System.Globalization;
using System.Diagnostics;
using System.Reflection;
using System.Text;
@@ -54,8 +53,10 @@ services.AddCycloneDxNormalizer();
services.AddOpenVexNormalizer();
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
// TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized.
services.Configure<AirgapOptions>(configuration.GetSection(AirgapOptions.SectionName));
services.AddSingleton<AirgapImportValidator>();
services.AddSingleton<AirgapSignerTrustService>();
services.AddSingleton<AirgapModeEnforcer>();
services.AddSingleton<ConsoleTelemetry>();
services.AddMemoryCache();
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
@@ -185,7 +186,7 @@ app.MapGet("/openapi/excititor.json", () =>
get = new
{
summary = "Service status (aggregation-only metadata)",
responses = new
responses = new Dictionary<string, object>
{
["200"] = new
{
@@ -219,7 +220,7 @@ app.MapGet("/openapi/excititor.json", () =>
get = new
{
summary = "Health check",
responses = new
responses = new Dictionary<string, object>
{
["200"] = new
{
@@ -254,7 +255,7 @@ app.MapGet("/openapi/excititor.json", () =>
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false, description = "Numeric cursor or Last-Event-ID" },
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false }
},
responses = new
responses = new Dictionary<string, object>
{
["200"] = new
{
@@ -331,7 +332,7 @@ app.MapGet("/openapi/excititor.json", () =>
}
}
},
responses = new
responses = new Dictionary<string, object>
{
["200"] = new { description = "Accepted" },
["400"] = new
@@ -448,16 +449,47 @@ app.MapGet("/openapi/excititor.json", () =>
app.MapPost("/airgap/v1/vex/import", async (
[FromServices] AirgapImportValidator validator,
[FromServices] AirgapSignerTrustService trustService,
[FromServices] AirgapModeEnforcer modeEnforcer,
[FromServices] IAirgapImportStore store,
[FromServices] ILoggerFactory loggerFactory,
[FromServices] TimeProvider timeProvider,
[FromBody] AirgapImportRequest request,
CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger("AirgapImport");
var nowUtc = timeProvider.GetUtcNow();
var tenantId = string.IsNullOrWhiteSpace(request.TenantId)
? "default"
: request.TenantId!.Trim().ToLowerInvariant();
var stalenessSeconds = request.SignedAt is null
? (int?)null
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
var timeline = new List<AirgapTimelineEntry>();
void RecordEvent(string eventType, string? code = null, string? message = null)
{
var entry = new AirgapTimelineEntry
{
EventType = eventType,
CreatedAt = nowUtc,
TenantId = tenantId,
BundleId = request.BundleId ?? string.Empty,
MirrorGeneration = request.MirrorGeneration ?? string.Empty,
StalenessSeconds = stalenessSeconds,
ErrorCode = code,
Message = message
};
timeline.Add(entry);
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
}
RecordEvent("airgap.import.started");
var errors = validator.Validate(request, nowUtc);
if (errors.Count > 0)
{
var first = errors[0];
RecordEvent("airgap.import.failed", first.Code, first.Message);
return Results.BadRequest(new
{
error = new
@@ -468,8 +500,22 @@ app.MapPost("/airgap/v1/vex/import", async (
});
}
if (!modeEnforcer.Validate(request, out var sealedCode, out var sealedMessage))
{
RecordEvent("airgap.import.failed", sealedCode, sealedMessage);
return Results.Json(new
{
error = new
{
code = sealedCode,
message = sealedMessage
}
}, statusCode: StatusCodes.Status403Forbidden);
}
if (!trustService.Validate(request, out var trustCode, out var trustMessage))
{
RecordEvent("airgap.import.failed", trustCode, trustMessage);
return Results.Json(new
{
error = new
@@ -480,9 +526,16 @@ app.MapPost("/airgap/v1/vex/import", async (
}, statusCode: StatusCodes.Status403Forbidden);
}
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
RecordEvent("airgap.import.completed");
var record = new AirgapImportRecord
{
Id = $"{request.BundleId}:{request.MirrorGeneration}",
TenantId = tenantId,
BundleId = request.BundleId!,
MirrorGeneration = request.MirrorGeneration!,
SignedAt = request.SignedAt!.Value,
@@ -491,7 +544,11 @@ app.MapPost("/airgap/v1/vex/import", async (
PayloadUrl = request.PayloadUrl,
Signature = request.Signature!,
TransparencyLog = request.TransparencyLog,
ImportedAt = nowUtc
ImportedAt = nowUtc,
PortableManifestPath = manifestPath,
PortableManifestHash = manifestHash,
EvidenceLockerPath = evidenceLockerPath,
Timeline = timeline
};
try
@@ -500,6 +557,7 @@ app.MapPost("/airgap/v1/vex/import", async (
}
catch (DuplicateAirgapImportException dup)
{
RecordEvent("airgap.import.failed", "AIRGAP_IMPORT_DUPLICATE", dup.Message);
return Results.Conflict(new
{
error = new
@@ -513,10 +571,20 @@ app.MapPost("/airgap/v1/vex/import", async (
return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new
{
bundleId = request.BundleId,
generation = request.MirrorGeneration
generation = request.MirrorGeneration,
manifest = manifestPath,
evidence = evidenceLockerPath,
manifestSha256 = manifestHash
});
});
static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
app.MapPost("/v1/attestations/verify", async (
[FromServices] IVexAttestationClient attestationClient,
[FromBody] AttestationVerifyRequest request,
@@ -1548,6 +1616,15 @@ app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) =>
app.Run();
internal sealed record ExcititorTimelineEvent(
string Type,
string Tenant,
string Source,
int Count,
int Errors,
string? TraceId,
string OccurredAt);
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);

View File

@@ -0,0 +1,65 @@
using System;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Options;
namespace StellaOps.Excititor.WebService.Services;
internal sealed class AirgapModeEnforcer
{
private readonly AirgapOptions _options;
private readonly ILogger<AirgapModeEnforcer> _logger;
public AirgapModeEnforcer(IOptions<AirgapOptions> options, ILogger<AirgapModeEnforcer> logger)
{
_options = options.Value;
_logger = logger;
}
public bool Validate(AirgapImportRequest request, out string? errorCode, out string? message)
{
errorCode = null;
message = null;
if (!_options.SealedMode)
{
return true;
}
if (_options.MirrorOnly && !string.IsNullOrWhiteSpace(request.PayloadUrl) && LooksLikeExternal(request.PayloadUrl))
{
errorCode = "AIRGAP_EGRESS_BLOCKED";
message = "Sealed mode forbids external payload URLs; stage bundle via mirror/portable media.";
_logger.LogWarning("Blocked airgap import because payloadUrl points to external location: {Url}", request.PayloadUrl);
return false;
}
if (_options.TrustedPublishers.Count > 0 && !string.IsNullOrWhiteSpace(request.Publisher))
{
var allowed = _options.TrustedPublishers.Any(p => string.Equals(p, request.Publisher, StringComparison.OrdinalIgnoreCase));
if (!allowed)
{
errorCode = "AIRGAP_SOURCE_UNTRUSTED";
message = $"Publisher '{request.Publisher}' is not allowlisted for sealed-mode imports.";
_logger.LogWarning("Blocked airgap import because publisher {Publisher} is not allowlisted.", request.Publisher);
return false;
}
}
return true;
}
private static bool LooksLikeExternal(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return false;
}
return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|| url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,27 @@
using System;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Excititor.Storage.Mongo;
[BsonIgnoreExtraElements]
public sealed class AirgapTimelineEntry
{
public string EventType { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public string TenantId { get; set; } = "default";
public string BundleId { get; set; } = string.Empty;
public string MirrorGeneration { get; set; } = string.Empty;
public int? StalenessSeconds { get; set; }
= null;
public string? ErrorCode { get; set; }
= null;
public string? Message { get; set; }
= null;
}

View File

@@ -316,6 +316,8 @@ public sealed class AirgapImportRecord
[BsonId]
public string Id { get; set; } = default!;
public string TenantId { get; set; } = "default";
public string BundleId { get; set; } = default!;
public string MirrorGeneration { get; set; } = default!;
@@ -333,6 +335,14 @@ public sealed class AirgapImportRecord
public string? TransparencyLog { get; set; } = null;
public DateTimeOffset ImportedAt { get; set; } = DateTimeOffset.UtcNow;
public string PortableManifestPath { get; set; } = string.Empty;
public string PortableManifestHash { get; set; } = string.Empty;
public string EvidenceLockerPath { get; set; } = string.Empty;
public List<AirgapTimelineEntry> Timeline { get; set; } = new();
}
[BsonIgnoreExtraElements]

View File

@@ -31,8 +31,15 @@ public sealed class AirgapImportValidatorTests
[Fact]
public void Validate_InvalidHash_ReturnsError()
{
var req = Valid();
req.PayloadHash = "not-a-hash";
var req = new AirgapImportRequest
{
BundleId = "bundle-123",
MirrorGeneration = "5",
Publisher = "stellaops",
PayloadHash = "not-a-hash",
Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }),
SignedAt = _now
};
var result = _validator.Validate(req, _now);
@@ -42,8 +49,15 @@ public sealed class AirgapImportValidatorTests
[Fact]
public void Validate_InvalidSignature_ReturnsError()
{
var req = Valid();
req.Signature = "???";
var req = new AirgapImportRequest
{
BundleId = "bundle-123",
MirrorGeneration = "5",
Publisher = "stellaops",
PayloadHash = "sha256:" + new string('b', 64),
Signature = "???",
SignedAt = _now
};
var result = _validator.Validate(req, _now);
@@ -53,8 +67,15 @@ public sealed class AirgapImportValidatorTests
[Fact]
public void Validate_MirrorGenerationNonNumeric_ReturnsError()
{
var req = Valid();
req.MirrorGeneration = "abc";
var req = new AirgapImportRequest
{
BundleId = "bundle-123",
MirrorGeneration = "abc",
Publisher = "stellaops",
PayloadHash = "sha256:" + new string('b', 64),
Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }),
SignedAt = _now
};
var result = _validator.Validate(req, _now);
@@ -64,8 +85,15 @@ public sealed class AirgapImportValidatorTests
[Fact]
public void Validate_SignedAtTooOld_ReturnsError()
{
var req = Valid();
req.SignedAt = _now.AddSeconds(-10);
var req = new AirgapImportRequest
{
BundleId = "bundle-123",
MirrorGeneration = "5",
Publisher = "stellaops",
PayloadHash = "sha256:" + new string('b', 64),
Signature = Convert.ToBase64String(new byte[] { 5, 6, 7 }),
SignedAt = _now.AddSeconds(-10)
};
var result = _validator.Validate(req, _now);

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public class AirgapModeEnforcerTests
{
[Fact]
public void Validate_Allows_WhenNotSealed()
{
var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = false }), NullLogger<AirgapModeEnforcer>.Instance);
var ok = enforcer.Validate(new AirgapImportRequest { PayloadUrl = "https://example.com" }, out var code, out var message);
Assert.True(ok);
Assert.Null(code);
Assert.Null(message);
}
[Fact]
public void Validate_Blocks_ExternalUrl_WhenSealed()
{
var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = true, MirrorOnly = true }), NullLogger<AirgapModeEnforcer>.Instance);
var ok = enforcer.Validate(new AirgapImportRequest { PayloadUrl = "https://example.com" }, out var code, out var message);
Assert.False(ok);
Assert.Equal("AIRGAP_EGRESS_BLOCKED", code);
Assert.NotNull(message);
}
[Fact]
public void Validate_Blocks_Untrusted_Publisher_WhenAllowlistSet()
{
var enforcer = new AirgapModeEnforcer(Microsoft.Extensions.Options.Options.Create(new AirgapOptions { SealedMode = true, TrustedPublishers = { "mirror-a" } }), NullLogger<AirgapModeEnforcer>.Instance);
var ok = enforcer.Validate(new AirgapImportRequest { Publisher = "mirror-b" }, out var code, out var message);
Assert.False(ok);
Assert.Equal("AIRGAP_SOURCE_UNTRUSTED", code);
Assert.NotNull(message);
}
}

View File

@@ -29,6 +29,8 @@
<ItemGroup>
<Compile Remove="**/*.cs" />
<Compile Include="AirgapImportEndpointTests.cs" />
<Compile Include="AirgapImportValidatorTests.cs" />
<Compile Include="AirgapModeEnforcerTests.cs" />
<Compile Include="EvidenceTelemetryTests.cs" />
<Compile Include="DevRuntimeEnvironmentStub.cs" />
<Compile Include="TestAuthentication.cs" />

View File

@@ -0,0 +1,69 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notify.Queue;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class AttestationEventEndpointTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public AttestationEventEndpointTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Attestation_event_is_published_to_queue()
{
var recordingQueue = new RecordingNotifyEventQueue();
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<INotifyEventQueue>();
services.AddSingleton<INotifyEventQueue>(recordingQueue);
});
}).CreateClient();
var request = new AttestationEventRequest
{
EventId = Guid.NewGuid(),
Kind = "authority.keys.rotated",
Actor = "authority",
Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
Payload = new System.Text.Json.Nodes.JsonObject
{
["rotation"] = new System.Text.Json.Nodes.JsonObject
{
["batchId"] = "batch-42",
["executedAt"] = "2025-11-24T00:00:00Z"
}
}
};
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/attestation-events")
{
Content = JsonContent.Create(request)
};
message.Headers.Add("X-StellaOps-Tenant", "tenant-sample");
var response = await client.SendAsync(message, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Single(recordingQueue.Published);
var published = recordingQueue.Published.Single();
Assert.Equal("authority.keys.rotated", published.Event.Kind);
Assert.Equal("tenant-sample", published.Event.Tenant);
Assert.Equal("notify:events", published.Stream);
}
}

View File

@@ -0,0 +1,62 @@
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.WebService.Setup;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class AttestationTemplateSeederTests
{
[Fact]
public async Task SeedTemplates_and_routing_load_from_offline_bundle()
{
var templateRepo = new InMemoryTemplateRepository();
var channelRepo = new InMemoryChannelRepository();
var ruleRepo = new InMemoryRuleRepository();
var logger = NullLogger<AttestationTemplateSeeder>.Instance;
var contentRoot = LocateRepoRoot();
var seededTemplates = await AttestationTemplateSeeder.SeedTemplatesAsync(
templateRepo,
contentRoot,
logger,
TestContext.Current.CancellationToken);
var seededRouting = await AttestationTemplateSeeder.SeedRoutingAsync(
channelRepo,
ruleRepo,
contentRoot,
logger,
TestContext.Current.CancellationToken);
Assert.True(seededTemplates >= 6, "Expected attestation templates to be seeded.");
Assert.True(seededRouting >= 3, "Expected attestation routing seed to create channels and rules.");
var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
Assert.Contains(templates, t => t.Key == "tmpl-attest-key-rotation");
Assert.Contains(templates, t => t.Key == "tmpl-attest-transparency-anomaly");
var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
Assert.Contains(rules, r => r.Match.EventKinds.Contains("authority.keys.rotated"));
Assert.Contains(rules, r => r.Match.EventKinds.Contains("attestor.transparency.anomaly"));
}
private static string LocateRepoRoot()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
if (File.Exists(Path.Combine(directory, "StellaOps.sln")) ||
File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln")))
{
return directory;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate repository root.");
}
}

View File

@@ -0,0 +1,70 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notify.Queue;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class RiskEventEndpointTests : IClassFixture<NotifierApplicationFactory>
{
private readonly NotifierApplicationFactory _factory;
public RiskEventEndpointTests(NotifierApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Risk_event_is_published_to_queue()
{
var recordingQueue = new RecordingNotifyEventQueue();
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<INotifyEventQueue>();
services.AddSingleton<INotifyEventQueue>(recordingQueue);
});
}).CreateClient();
var request = new RiskEventRequest
{
EventId = Guid.NewGuid(),
Kind = "risk.profile.severity.changed",
Actor = "risk-engine",
Timestamp = DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
Payload = new System.Text.Json.Nodes.JsonObject
{
["profile"] = new System.Text.Json.Nodes.JsonObject
{
["id"] = "stellaops://risk/profile/example@2025.11",
["version"] = "2025.11"
},
["previous"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "medium" },
["current"] = new System.Text.Json.Nodes.JsonObject { ["severity"] = "high" }
}
};
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/risk-events")
{
Content = JsonContent.Create(request)
};
message.Headers.Add("X-StellaOps-Tenant", "tenant-sample");
var response = await client.SendAsync(message, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Single(recordingQueue.Published);
var published = recordingQueue.Published.Single();
Assert.Equal("risk.profile.severity.changed", published.Event.Kind);
Assert.Equal("tenant-sample", published.Event.Tenant);
Assert.Equal("notify:events", published.Stream);
}
}

View File

@@ -0,0 +1,62 @@
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.WebService.Setup;
using Xunit;
namespace StellaOps.Notifier.Tests;
public sealed class RiskTemplateSeederTests
{
[Fact]
public async Task SeedTemplates_and_routing_load_from_offline_bundle()
{
var templateRepo = new InMemoryTemplateRepository();
var channelRepo = new InMemoryChannelRepository();
var ruleRepo = new InMemoryRuleRepository();
var logger = NullLogger<RiskTemplateSeeder>.Instance;
var contentRoot = LocateRepoRoot();
var seededTemplates = await RiskTemplateSeeder.SeedTemplatesAsync(
templateRepo,
contentRoot,
logger,
TestContext.Current.CancellationToken);
var seededRouting = await RiskTemplateSeeder.SeedRoutingAsync(
channelRepo,
ruleRepo,
contentRoot,
logger,
TestContext.Current.CancellationToken);
Assert.True(seededTemplates >= 4, "Expected risk templates to be seeded.");
Assert.True(seededRouting >= 4, "Expected risk routing seed to create channels and rules.");
var templates = await templateRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
Assert.Contains(templates, t => t.Key == "tmpl-risk-severity-change");
Assert.Contains(templates, t => t.Key == "tmpl-risk-profile-state");
var rules = await ruleRepo.ListAsync("bootstrap", TestContext.Current.CancellationToken);
Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.severity.changed"));
Assert.Contains(rules, r => r.Match.EventKinds.Contains("risk.profile.published"));
}
private static string LocateRepoRoot()
{
var directory = AppContext.BaseDirectory;
while (directory != null)
{
if (File.Exists(Path.Combine(directory, "StellaOps.sln")) ||
File.Exists(Path.Combine(directory, "StellaOps.Notifier.sln")))
{
return directory;
}
directory = Directory.GetParent(directory)?.FullName;
}
throw new InvalidOperationException("Unable to locate repository root.");
}
}

View File

@@ -0,0 +1,21 @@
using StellaOps.Notify.Queue;
namespace StellaOps.Notifier.Tests.Support;
internal sealed class RecordingNotifyEventQueue : INotifyEventQueue
{
private readonly List<NotifyQueueEventMessage> _messages = new();
public IReadOnlyList<NotifyQueueEventMessage> Published => _messages;
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
public ValueTask PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
{
_messages.Add(message);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed record AttestationEventRequest
{
public Guid EventId { get; init; }
/// <summary>
/// Event kind, e.g. authority.keys.rotated, authority.keys.revoked, attestor.transparency.anomaly.
/// </summary>
public string? Kind { get; init; }
public string? Actor { get; init; }
public DateTimeOffset? Timestamp { get; init; }
public JsonObject? Payload { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? ResumeToken { get; init; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed record RiskEventRequest
{
public Guid EventId { get; init; }
/// <summary>
/// risk.profile.severity.changed | risk.profile.published | risk.profile.deprecated | risk.profile.thresholds.changed
/// </summary>
public string? Kind { get; init; }
public string? Actor { get; init; }
public DateTimeOffset? Timestamp { get; init; }
public JsonObject? Payload { get; init; }
public IDictionary<string, string>? Attributes { get; init; }
public string? ResumeToken { get; init; }
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -31,6 +32,8 @@ if (!isTesting)
builder.Services.AddNotifyMongoStorage(mongoSection);
builder.Services.AddHostedService<MongoInitializationHostedService>();
builder.Services.AddHostedService<PackApprovalTemplateSeeder>();
builder.Services.AddHostedService<AttestationTemplateSeeder>();
builder.Services.AddHostedService<RiskTemplateSeeder>();
}
// Fallback no-op event queue for environments that do not configure a real backend.
@@ -173,6 +176,122 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
return Results.Accepted();
});
app.MapPost("/api/v1/notify/attestation-events", async (
HttpContext context,
AttestationEventRequest request,
INotifyEventQueue? eventQueue,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
if (string.IsNullOrWhiteSpace(request.Kind))
{
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
}
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
if (eventQueue is not null)
{
var payload = request.Payload ?? new JsonObject();
var notifyEvent = NotifyEvent.Create(
eventId: eventId,
kind: request.Kind!,
tenant: tenantId,
ts: ts,
payload: payload,
attributes: request.Attributes ?? new Dictionary<string, string>(),
actor: request.Actor,
version: "1");
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
idempotencyKey = $"attestation|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
}
await eventQueue.PublishAsync(
new NotifyQueueEventMessage(
notifyEvent,
stream: "notify:events",
idempotencyKey: idempotencyKey,
partitionKey: tenantId,
traceId: context.TraceIdentifier),
context.RequestAborted).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
{
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
}
return Results.Accepted();
});
app.MapPost("/api/v1/notify/risk-events", async (
HttpContext context,
RiskEventRequest request,
INotifyEventQueue? eventQueue,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
if (string.IsNullOrWhiteSpace(request.Kind))
{
return Results.BadRequest(Error("invalid_request", "kind is required.", context));
}
var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid();
var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow();
if (eventQueue is not null)
{
var payload = request.Payload ?? new JsonObject();
var notifyEvent = NotifyEvent.Create(
eventId: eventId,
kind: request.Kind!,
tenant: tenantId,
ts: ts,
payload: payload,
attributes: request.Attributes ?? new Dictionary<string, string>(),
actor: request.Actor,
version: "1");
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
idempotencyKey = $"risk|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}";
}
await eventQueue.PublishAsync(
new NotifyQueueEventMessage(
notifyEvent,
stream: "notify:events",
idempotencyKey: idempotencyKey,
partitionKey: tenantId,
traceId: context.TraceIdentifier),
context.RequestAborted).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
{
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
}
return Results.Accepted();
});
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
HttpContext context,
string packId,

View File

@@ -0,0 +1,256 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Setup;
/// <summary>
/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class AttestationTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<AttestationTemplateSeeder> _logger;
public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<AttestationTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping attestation template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (templatesSeeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} attestation templates from offline bundle.", templatesSeeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping attestation routing seed.");
return;
}
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (routingSeeded > 0)
{
_logger.LogInformation("Seeded default attestation routing (channels + rules).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var templateDir = LocateAttestationTemplatesPath(contentRootPath);
if (templateDir is null)
{
logger.LogWarning("Attestation templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
var count = 0;
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
{
try
{
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
var samplePath = LocateAttestationRulesPath(contentRootPath);
if (samplePath is null)
{
logger.LogWarning("Attestation rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
return 0;
}
using var stream = File.OpenRead(samplePath);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var tenant = "bootstrap";
var channelsElement = doc.RootElement.GetProperty("channels");
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(ToChannel)
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
{
var model = ToRule(rule, tenant);
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
}
return channels.Length + rulesElement.GetArrayLength();
}
private static NotifyRule ToRule(JsonElement element, string tenant)
{
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
var name = element.GetProperty("name").GetString() ?? ruleId;
var enabled = element.GetProperty("enabled").GetBoolean();
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
NotifyRuleAction.Create(
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
channel: action.GetProperty("channel").GetString() ?? string.Empty,
template: action.GetProperty("template").GetString() ?? string.Empty,
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
return NotifyRule.Create(
ruleId: ruleId,
tenantId: tenant,
name: name,
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
actions: actions,
enabled: enabled,
description: "Seeded attestation routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
var name = element.GetProperty("name").GetString() ?? channelId;
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
var config = NotifyChannelConfig.Create(
secretRef: secretRef,
endpoint: endpoint,
target: target);
return NotifyChannel.Create(
channelId: channelId,
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
name: name,
type: type,
config: config,
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
}
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = doc.RootElement;
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = root.GetProperty("locale").GetString() ?? "en-US";
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var body = root.GetProperty("body").GetString() ?? string.Empty;
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
{
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
}
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:attestation");
}
private static string? LocateAttestationTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "offline", "notifier", "templates", "attestation"),
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "attestation")
};
return candidates.FirstOrDefault(Directory.Exists);
}
private static string? LocateAttestationRulesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "docs", "attestation-rules.sample.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}

View File

@@ -0,0 +1,258 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Xml;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Setup;
/// <summary>
/// Seeds risk templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class RiskTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<RiskTemplateSeeder> _logger;
public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<RiskTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping risk template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (templatesSeeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} risk templates from offline bundle.", templatesSeeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping risk routing seed.");
return;
}
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (routingSeeded > 0)
{
_logger.LogInformation("Seeded default risk routing (channels + rules).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var templateDir = LocateRiskTemplatesPath(contentRootPath);
if (templateDir is null)
{
logger.LogWarning("Risk templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
var count = 0;
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
{
try
{
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
var samplePath = LocateRiskRulesPath(contentRootPath);
if (samplePath is null)
{
logger.LogWarning("Risk rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
return 0;
}
await using var stream = File.OpenRead(samplePath);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var tenant = "bootstrap";
var channelsElement = doc.RootElement.GetProperty("channels");
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(ToChannel)
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
{
var model = ToRule(rule, tenant);
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
}
return channels.Length + rulesElement.GetArrayLength();
}
private static NotifyRule ToRule(JsonElement element, string tenant)
{
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
var name = element.GetProperty("name").GetString() ?? ruleId;
var enabled = element.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true;
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
NotifyRuleAction.Create(
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
channel: action.GetProperty("channel").GetString() ?? string.Empty,
template: action.GetProperty("template").GetString() ?? string.Empty,
locale: action.TryGetProperty("locale", out var loc) ? loc.GetString() : null,
throttle: action.TryGetProperty("throttle", out var throttle) ? XmlConvert.ToTimeSpan(throttle.GetString() ?? string.Empty) : default,
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
return NotifyRule.Create(
ruleId: ruleId,
tenantId: tenant,
name: name,
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
actions: actions,
enabled: enabled,
description: "Seeded risk routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
var name = element.GetProperty("name").GetString() ?? channelId;
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
var config = NotifyChannelConfig.Create(
secretRef: secretRef,
endpoint: endpoint,
target: target);
return NotifyChannel.Create(
channelId: channelId,
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
name: name,
type: type,
config: config,
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
}
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = doc.RootElement;
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = root.GetProperty("locale").GetString() ?? "en-US";
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var body = root.GetProperty("body").GetString() ?? string.Empty;
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
{
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
}
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:risk");
}
private static string? LocateRiskTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "offline", "notifier", "templates", "risk"),
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "risk")
};
return candidates.FirstOrDefault(Directory.Exists);
}
private static string? LocateRiskRulesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "risk-rules.sample.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "risk-rules.sample.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "risk-rules.sample.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}

View File

@@ -0,0 +1,80 @@
{
"rules": [
{
"ruleId": "risk-severity-change",
"name": "Risk severity escalation/downgrade",
"enabled": true,
"tenantId": "bootstrap",
"match": {
"eventKinds": [
"risk.profile.severity.changed"
]
},
"actions": [
{
"actionId": "act-risk-severity-slack",
"enabled": true,
"channel": "slack-risk",
"template": "tmpl-risk-severity-change",
"locale": "en-us",
"throttle": "PT5M"
},
{
"actionId": "act-risk-severity-email",
"enabled": true,
"channel": "email-risk",
"template": "tmpl-risk-severity-change",
"locale": "en-us",
"throttle": "PT10M"
}
]
},
{
"ruleId": "risk-profile-state",
"name": "Risk profile published/deprecated",
"enabled": true,
"tenantId": "bootstrap",
"match": {
"eventKinds": [
"risk.profile.published",
"risk.profile.deprecated",
"risk.profile.thresholds.changed"
]
},
"actions": [
{
"actionId": "act-risk-profile-slack",
"enabled": true,
"channel": "slack-risk",
"template": "tmpl-risk-profile-state",
"locale": "en-us"
},
{
"actionId": "act-risk-profile-email",
"enabled": true,
"channel": "email-risk",
"template": "tmpl-risk-profile-state",
"locale": "en-us"
}
]
}
],
"channels": [
{
"channelId": "slack-risk",
"type": "slack",
"tenantId": "bootstrap",
"name": "Slack · Risk",
"endpoint": "https://hooks.slack.local/services/T000/B000/RISK",
"secretRef": "ref://notify/channels/slack/risk"
},
{
"channelId": "email-risk",
"type": "email",
"tenantId": "bootstrap",
"name": "Email · Risk",
"target": "risk-team@example.com",
"secretRef": "ref://notify/channels/email/risk"
}
]
}

View File

@@ -3,13 +3,13 @@
| ID | Status | Owner(s) | Notes |
| --- | --- | --- | --- |
| NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Notifications Service Guild | Attestation template suite complete; Slack expiry template added; coverage tests guard required channels. |
| NOTIFY-ATTEST-74-002 | TODO | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events + transparency witness failures (depends on 74-001). |
| NOTIFY-ATTEST-74-002 | DONE (2025-11-24) | Notifications Service Guild · KMS Guild | Attestation event ingestion endpoint + seed routing/templates for key rotation, revocation, and transparency witness failures. |
| NOTIFY-OAS-61-001 | DONE (2025-11-17) | Notifications Service Guild · API Contracts Guild | OAS updated with rules/templates/incidents/quiet hours and standard error envelope. |
| NOTIFY-OAS-61-002 | DONE (2025-11-17) | Notifications Service Guild | `.well-known/openapi` discovery endpoint with scope metadata implemented. |
| NOTIFY-OAS-62-001 | DONE (2025-11-17) | Notifications Service Guild · SDK Generator Guild | SDK usage examples + smoke tests (depends on 61-002). |
| NOTIFY-OAS-63-001 | DONE (2025-11-17) | Notifications Service Guild · API Governance Guild | Deprecation headers + template notices for retiring APIs (depends on 62-001). |
| NOTIFY-OBS-51-001 | DONE (2025-11-22) | Notifications Service Guild · Observability Guild | SLO webhook sink validated (`HttpEgressSloSinkTests`, `EventProcessorTests`); TRX: `StellaOps.Notifier.Tests/TestResults/notifier-slo-tests.trx`. |
| NOTIFY-OBS-55-001 | DONE (2025-11-22) | Notifications Service Guild · Ops Guild | Incident mode start/stop notifications; templates + importable rules with quiet-hour overrides and legal logging metadata. |
| NOTIFY-RISK-66-001 | TODO | Notifications Service Guild · Risk Engine Guild | Trigger risk severity escalation/downgrade notifications (waiting on Policy export). |
| NOTIFY-RISK-67-001 | TODO | Notifications Service Guild · Policy Guild | Notify when risk profiles publish/deprecate/threshold-change (depends on 66-001). |
| NOTIFY-RISK-68-001 | TODO | Notifications Service Guild | Per-profile routing rules + quiet hours for risk alerts (depends on 67-001). |
| NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. |
| NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. |
| NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. |

View File

@@ -26,3 +26,5 @@ Host signed Task Pack bundles with provenance and RBAC for Epic12. Ensure pac
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
- 6. Registry API expectations: require `X-API-Key` when configured and tenant scoping via `X-StellaOps-Tenant` (or `tenantId` on upload). Content/provenance downloads must emit digest headers (`X-Content-Digest`, `X-Provenance-Digest`) and respect tenant allowlists.
- 7. Lifecycle/parity/signature rotation endpoints require tenant headers; offline seed export supports per-tenant filtering and deterministic zip output. All mutating calls emit audit log entries (file `audit.ndjson` or Mongo `packs_audit_log`).

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

View File

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

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileAttestationRepository : IAttestationRepository
{
private readonly string _indexPath;
private readonly string _contentPath;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileAttestationRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_indexPath = Path.Combine(root, "attestations.ndjson");
_contentPath = Path.Combine(root, "attestations");
Directory.CreateDirectory(_contentPath);
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
var fileName = GetFileName(record.PackId, record.Type);
await File.WriteAllBytesAsync(fileName, content, cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var list = await ListAsync(packId, cancellationToken).ConfigureAwait(false);
return list.LastOrDefault(r => string.Equals(r.Type, type, StringComparison.OrdinalIgnoreCase));
}
public async Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
if (!File.Exists(_indexPath))
{
return Array.Empty<AttestationRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false);
return lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<AttestationRecord>(l, _jsonOptions))
.Where(r => r is not null && string.Equals(r!.PackId, packId, StringComparison.OrdinalIgnoreCase))
.Cast<AttestationRecord>()
.OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
.ToList();
}
finally
{
_mutex.Release();
}
}
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var file = GetFileName(packId, type);
if (!File.Exists(file))
{
return Task.FromResult<byte[]?>(null);
}
return File.ReadAllBytesAsync(file, cancellationToken).ContinueWith(t => (byte[]?)t.Result, cancellationToken);
}
private string GetFileName(string packId, string type)
{
var safe = packId.Replace('/', '_').Replace('@', '_');
var safeType = type.Replace('/', '_');
return Path.Combine(_contentPath, $"{safe}_{safeType}.bin");
}
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileAuditRepository : IAuditRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileAuditRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "audit.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<AuditRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<AuditRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<AuditRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<AuditRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records
.OrderBy(r => r.OccurredAtUtc)
.ThenBy(r => r.PackId, StringComparer.OrdinalIgnoreCase)
.ToList();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,91 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileLifecycleRepository : ILifecycleRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileLifecycleRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "lifecycle.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return null;
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
return lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<LifecycleRecord>(l, _jsonOptions))
.Where(r => r is not null)
.Cast<LifecycleRecord>()
.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<LifecycleRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<LifecycleRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<LifecycleRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<LifecycleRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,74 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileMirrorRepository : IMirrorRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileMirrorRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "mirrors.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<MirrorSourceRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<MirrorSourceRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<MirrorSourceRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<MirrorSourceRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList();
}
finally
{
_mutex.Release();
}
}
public async Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
{
var list = await ListAsync(null, cancellationToken).ConfigureAwait(false);
return list.LastOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FilePackRepository : IPackRepository
{
private readonly string _root;
private readonly string _indexPath;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FilePackRepository(string root)
{
_root = string.IsNullOrWhiteSpace(root) ? Path.GetFullPath("data/packs") : Path.GetFullPath(root);
_indexPath = Path.Combine(_root, "index.ndjson");
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
Directory.CreateDirectory(_root);
Directory.CreateDirectory(Path.Combine(_root, "blobs"));
}
public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_'));
await File.WriteAllBytesAsync(blobPath, content, cancellationToken).ConfigureAwait(false);
if (provenance is { Length: > 0 } && record.ProvenanceDigest is not null)
{
var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_'));
Directory.CreateDirectory(Path.GetDirectoryName(provPath)!);
await File.WriteAllBytesAsync(provPath, provenance, cancellationToken).ConfigureAwait(false);
}
await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packId);
var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false);
return records.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
}
public async Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records
.OrderBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private async Task<IEnumerable<PackRecord>> ReadAllAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_indexPath))
{
return Array.Empty<PackRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false);
return lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => JsonSerializer.Deserialize<PackRecord>(line, _jsonOptions))
.Where(r => r is not null)!;
}
finally
{
_mutex.Release();
}
}
public async Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_'));
if (!File.Exists(blobPath))
{
return null;
}
return await File.ReadAllBytesAsync(blobPath, cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record?.ProvenanceDigest is null)
{
return null;
}
var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_'));
if (!File.Exists(provPath))
{
return null;
}
return await File.ReadAllBytesAsync(provPath, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.FileSystem;
public sealed class FileParityRepository : IParityRepository
{
private readonly string _path;
private readonly JsonSerializerOptions _jsonOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public FileParityRepository(string rootPath)
{
var root = string.IsNullOrWhiteSpace(rootPath) ? Path.GetFullPath("data/packs") : Path.GetFullPath(rootPath);
Directory.CreateDirectory(root);
_path = Path.Combine(root, "parity.ndjson");
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// naive append; last write wins on read
await using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
var json = JsonSerializer.Serialize(record, _jsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
public async Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return null;
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
return lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<ParityRecord>(l, _jsonOptions))
.Where(r => r is not null)
.Cast<ParityRecord>()
.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(_path))
{
return Array.Empty<ParityRecord>();
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(_path, cancellationToken).ConfigureAwait(false);
IEnumerable<ParityRecord> records = lines
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => JsonSerializer.Deserialize<ParityRecord>(l, _jsonOptions))
.Where(r => r is not null)!
.Cast<ParityRecord>();
if (!string.IsNullOrWhiteSpace(tenantId))
{
records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return records.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryAttestationRepository : IAttestationRepository
{
private readonly ConcurrentDictionary<(string PackId, string Type), AttestationRecord> _records = new();
private readonly ConcurrentDictionary<(string PackId, string Type), byte[]> _content = new();
public Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
_records[(record.PackId, record.Type)] = record;
_content[(record.PackId, record.Type)] = content.ToArray();
return Task.CompletedTask;
}
public Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
_records.TryGetValue((packId, type), out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
var result = _records.Values.Where(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<AttestationRecord>>(result);
}
public Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
_content.TryGetValue((packId, type), out var bytes);
return Task.FromResult<byte[]?>(bytes?.ToArray());
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryAuditRepository : IAuditRepository
{
private readonly ConcurrentBag<AuditRecord> _events = new();
public Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_events.Add(record);
return Task.CompletedTask;
}
public Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<AuditRecord> result = _events;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(e => string.Equals(e.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result
.OrderBy(e => e.OccurredAtUtc)
.ThenBy(e => e.PackId, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<AuditRecord>>(ordered);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryLifecycleRepository : ILifecycleRepository
{
private readonly ConcurrentDictionary<string, LifecycleRecord> _records = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_records[record.PackId] = record;
return Task.CompletedTask;
}
public Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
_records.TryGetValue(packId, out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<LifecycleRecord> result = _records.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
return Task.FromResult<IReadOnlyList<LifecycleRecord>>(ordered);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryMirrorRepository : IMirrorRepository
{
private readonly ConcurrentDictionary<string, MirrorSourceRecord> _records = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_records[record.Id] = record;
return Task.CompletedTask;
}
public Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<MirrorSourceRecord> result = _records.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
return Task.FromResult<IReadOnlyList<MirrorSourceRecord>>(result.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase).ToList());
}
public Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
{
_records.TryGetValue(id, out var record);
return Task.FromResult(record);
}
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
/// <summary>
/// Simple in-memory repository for early development and tests.
/// </summary>
public sealed class InMemoryPackRepository : IPackRepository
{
private readonly ConcurrentDictionary<string, PackRecord> _packs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte[]> _content = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte[]> _provenance = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
_packs[record.PackId] = record;
_content[record.Digest] = content.ToArray();
if (provenance is { Length: > 0 } && !string.IsNullOrWhiteSpace(record.ProvenanceDigest))
{
_provenance[record.ProvenanceDigest!] = provenance.ToArray();
}
return Task.CompletedTask;
}
public Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
_packs.TryGetValue(packId, out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<PackRecord> result = _packs.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(p => string.Equals(p.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result
.OrderBy(p => p.TenantId, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
return Task.FromResult<IReadOnlyList<PackRecord>>(ordered);
}
public Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
if (_packs.TryGetValue(packId, out var record) && _content.TryGetValue(record.Digest, out var bytes))
{
return Task.FromResult<byte[]?>(bytes.ToArray());
}
return Task.FromResult<byte[]?>(null);
}
public Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
if (_packs.TryGetValue(packId, out var record) && record.ProvenanceDigest is not null && _provenance.TryGetValue(record.ProvenanceDigest, out var bytes))
{
return Task.FromResult<byte[]?>(bytes.ToArray());
}
return Task.FromResult<byte[]?>(null);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.Infrastructure.InMemory;
public sealed class InMemoryParityRepository : IParityRepository
{
private readonly ConcurrentDictionary<string, ParityRecord> _parity = new(StringComparer.OrdinalIgnoreCase);
public Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
_parity[record.PackId] = record;
return Task.CompletedTask;
}
public Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
_parity.TryGetValue(packId, out var record);
return Task.FromResult(record);
}
public Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ParityRecord> result = _parity.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
result = result.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var ordered = result.OrderBy(r => r.PackId, StringComparer.OrdinalIgnoreCase).ToList();
return Task.FromResult<IReadOnlyList<ParityRecord>>(ordered);
}
}

View File

@@ -0,0 +1,84 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoAttestationRepository : IAttestationRepository
{
private readonly IMongoCollection<AttestationDocument> _index;
private readonly IMongoCollection<AttestationBlob> _blobs;
public MongoAttestationRepository(IMongoDatabase database, MongoOptions options)
{
_index = database.GetCollection<AttestationDocument>(options.AttestationCollection ?? "packs_attestations");
_blobs = database.GetCollection<AttestationBlob>(options.AttestationBlobsCollection ?? "packs_attestation_blobs");
_index.Indexes.CreateOne(new CreateIndexModel<AttestationDocument>(Builders<AttestationDocument>.IndexKeys.Ascending(x => x.PackId).Ascending(x => x.Type), new CreateIndexOptions { Unique = true }));
_blobs.Indexes.CreateOne(new CreateIndexModel<AttestationBlob>(Builders<AttestationBlob>.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(AttestationRecord record, byte[] content, CancellationToken cancellationToken = default)
{
var doc = AttestationDocument.From(record);
await _index.ReplaceOneAsync(x => x.PackId == record.PackId && x.Type == record.Type, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
var blob = new AttestationBlob { Digest = record.Digest, Content = content };
await _blobs.ReplaceOneAsync(x => x.Digest == blob.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<AttestationRecord?> GetAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var doc = await _index.Find(x => x.PackId == packId && x.Type == type).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<AttestationRecord>> ListAsync(string packId, CancellationToken cancellationToken = default)
{
var docs = await _index.Find(x => x.PackId == packId).SortBy(x => x.Type).ToListAsync(cancellationToken).ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
public async Task<byte[]?> GetContentAsync(string packId, string type, CancellationToken cancellationToken = default)
{
var record = await GetAsync(packId, type, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return null;
}
var blob = await _blobs.Find(x => x.Digest == record.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return blob?.Content;
}
private sealed class AttestationDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Type { get; set; } = default!;
public string Digest { get; set; } = default!;
public DateTimeOffset CreatedAtUtc { get; set; }
public string? Notes { get; set; }
public AttestationRecord ToModel() => new(PackId, TenantId, Type, Digest, CreatedAtUtc, Notes);
public static AttestationDocument From(AttestationRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
Type = record.Type,
Digest = record.Digest,
CreatedAtUtc = record.CreatedAtUtc,
Notes = record.Notes
};
}
private sealed class AttestationBlob
{
public ObjectId Id { get; set; }
public string Digest { get; set; } = default!;
public byte[] Content { get; set; } = default!;
}
}

View File

@@ -0,0 +1,66 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoAuditRepository : IAuditRepository
{
private readonly IMongoCollection<AuditDocument> _collection;
public MongoAuditRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<AuditDocument>(options.AuditCollection ?? "packs_audit_log");
var indexKeys = Builders<AuditDocument>.IndexKeys
.Ascending(x => x.TenantId)
.Ascending(x => x.PackId)
.Ascending(x => x.OccurredAtUtc);
_collection.Indexes.CreateOne(new CreateIndexModel<AuditDocument>(indexKeys));
}
public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken = default)
{
var doc = AuditDocument.From(record);
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AuditRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<AuditDocument>.Filter.Empty
: Builders<AuditDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter)
.SortBy(x => x.OccurredAtUtc)
.ThenBy(x => x.PackId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
private sealed class AuditDocument
{
public ObjectId Id { get; set; }
public string? PackId { get; set; }
public string TenantId { get; set; } = default!;
public string Event { get; set; } = default!;
public DateTimeOffset OccurredAtUtc { get; set; }
public string? Actor { get; set; }
public string? Notes { get; set; }
public AuditRecord ToModel() => new(PackId, TenantId, Event, OccurredAtUtc, Actor, Notes);
public static AuditDocument From(AuditRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
Event = record.Event,
OccurredAtUtc = record.OccurredAtUtc,
Actor = record.Actor,
Notes = record.Notes
};
}
}

View File

@@ -0,0 +1,64 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoLifecycleRepository : ILifecycleRepository
{
private readonly IMongoCollection<LifecycleDocument> _collection;
public MongoLifecycleRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<LifecycleDocument>(options.LifecycleCollection ?? "packs_lifecycle");
_collection.Indexes.CreateOne(new CreateIndexModel<LifecycleDocument>(Builders<LifecycleDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(LifecycleRecord record, CancellationToken cancellationToken = default)
{
var doc = LifecycleDocument.From(record);
await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<LifecycleRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<LifecycleRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<LifecycleDocument>.Filter.Empty
: Builders<LifecycleDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter)
.SortBy(x => x.PackId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
private sealed class LifecycleDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string State { get; set; } = default!;
public string? Notes { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public LifecycleRecord ToModel() => new(PackId, TenantId, State, Notes, UpdatedAtUtc);
public static LifecycleDocument From(LifecycleRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
State = record.State,
Notes = record.Notes,
UpdatedAtUtc = record.UpdatedAtUtc
};
}
}

View File

@@ -0,0 +1,67 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoMirrorRepository : IMirrorRepository
{
private readonly IMongoCollection<MirrorDocument> _collection;
public MongoMirrorRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<MirrorDocument>(options.MirrorCollection ?? "packs_mirrors");
_collection.Indexes.CreateOne(new CreateIndexModel<MirrorDocument>(Builders<MirrorDocument>.IndexKeys.Ascending(x => x.Id), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(MirrorSourceRecord record, CancellationToken cancellationToken = default)
{
var doc = MirrorDocument.From(record);
await _collection.ReplaceOneAsync(x => x.Id == record.Id, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MirrorSourceRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<MirrorDocument>.Filter.Empty
: Builders<MirrorDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter).SortBy(x => x.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
public async Task<MirrorSourceRecord?> GetAsync(string id, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
private sealed class MirrorDocument
{
public ObjectId InternalId { get; set; }
public string Id { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Upstream { get; set; } = default!;
public bool Enabled { get; set; }
public string Status { get; set; } = default!;
public DateTimeOffset UpdatedAtUtc { get; set; }
public string? Notes { get; set; }
public DateTimeOffset? LastSuccessfulSyncUtc { get; set; }
public MirrorSourceRecord ToModel() => new(Id, TenantId, new Uri(Upstream), Enabled, Status, UpdatedAtUtc, Notes, LastSuccessfulSyncUtc);
public static MirrorDocument From(MirrorSourceRecord record) => new()
{
Id = record.Id,
TenantId = record.TenantId,
Upstream = record.UpstreamUri.ToString(),
Enabled = record.Enabled,
Status = record.Status,
UpdatedAtUtc = record.UpdatedAtUtc,
Notes = record.Notes,
LastSuccessfulSyncUtc = record.LastSuccessfulSyncUtc
};
}
}

View File

@@ -0,0 +1,123 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoPackRepository : IPackRepository
{
private readonly IMongoCollection<PackDocument> _packs;
private readonly IMongoCollection<PackContentDocument> _contents;
public MongoPackRepository(IMongoDatabase database, MongoOptions options)
{
ArgumentNullException.ThrowIfNull(database);
_packs = database.GetCollection<PackDocument>(options.PacksCollection);
_contents = database.GetCollection<PackContentDocument>(options.BlobsCollection);
_packs.Indexes.CreateOne(new CreateIndexModel<PackDocument>(Builders<PackDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
_packs.Indexes.CreateOne(new CreateIndexModel<PackDocument>(Builders<PackDocument>.IndexKeys.Ascending(x => x.TenantId).Ascending(x => x.Name).Ascending(x => x.Version)));
_contents.Indexes.CreateOne(new CreateIndexModel<PackContentDocument>(Builders<PackContentDocument>.IndexKeys.Ascending(x => x.Digest), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(content);
var packDoc = PackDocument.From(record);
await _packs.ReplaceOneAsync(x => x.PackId == record.PackId, packDoc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
var blob = new PackContentDocument
{
Digest = record.Digest,
Content = content,
ProvenanceDigest = record.ProvenanceDigest,
Provenance = provenance
};
await _contents.ReplaceOneAsync(x => x.Digest == record.Digest, blob, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<PackRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
var doc = await _packs.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<PackRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<PackDocument>.Filter.Empty
: Builders<PackDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _packs.Find(filter).SortBy(x => x.TenantId).ThenBy(x => x.Name).ThenBy(x => x.Version).ToListAsync(cancellationToken).ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToArray();
}
public async Task<byte[]?> GetContentAsync(string packId, CancellationToken cancellationToken = default)
{
var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (pack is null)
{
return null;
}
var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return blob?.Content;
}
public async Task<byte[]?> GetProvenanceAsync(string packId, CancellationToken cancellationToken = default)
{
var pack = await GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (pack is null || string.IsNullOrWhiteSpace(pack.ProvenanceDigest))
{
return null;
}
var blob = await _contents.Find(x => x.Digest == pack.Digest).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return blob?.Provenance;
}
private sealed class PackDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string Name { get; set; } = default!;
public string Version { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Digest { get; set; } = default!;
public string? Signature { get; set; }
public string? ProvenanceUri { get; set; }
public string? ProvenanceDigest { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public PackRecord ToModel() => new(PackId, Name, Version, TenantId, Digest, Signature, ProvenanceUri, ProvenanceDigest, CreatedAtUtc, Metadata);
public static PackDocument From(PackRecord model) => new()
{
PackId = model.PackId,
Name = model.Name,
Version = model.Version,
TenantId = model.TenantId,
Digest = model.Digest,
Signature = model.Signature,
ProvenanceUri = model.ProvenanceUri,
ProvenanceDigest = model.ProvenanceDigest,
CreatedAtUtc = model.CreatedAtUtc,
Metadata = model.Metadata?.ToDictionary(kv => kv.Key, kv => kv.Value)
};
}
private sealed class PackContentDocument
{
public ObjectId Id { get; set; }
public string Digest { get; set; } = default!;
public byte[] Content { get; set; } = Array.Empty<byte>();
public string? ProvenanceDigest { get; set; }
public byte[]? Provenance { get; set; }
}
}

View File

@@ -0,0 +1,64 @@
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
public sealed class MongoParityRepository : IParityRepository
{
private readonly IMongoCollection<ParityDocument> _collection;
public MongoParityRepository(IMongoDatabase database, MongoOptions options)
{
_collection = database.GetCollection<ParityDocument>(options.ParityCollection ?? "packs_parity_matrix");
_collection.Indexes.CreateOne(new CreateIndexModel<ParityDocument>(Builders<ParityDocument>.IndexKeys.Ascending(x => x.PackId), new CreateIndexOptions { Unique = true }));
}
public async Task UpsertAsync(ParityRecord record, CancellationToken cancellationToken = default)
{
var doc = ParityDocument.From(record);
await _collection.ReplaceOneAsync(x => x.PackId == record.PackId, doc, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<ParityRecord?> GetAsync(string packId, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.PackId == packId).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToModel();
}
public async Task<IReadOnlyList<ParityRecord>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var filter = string.IsNullOrWhiteSpace(tenantId)
? Builders<ParityDocument>.Filter.Empty
: Builders<ParityDocument>.Filter.Eq(x => x.TenantId, tenantId);
var docs = await _collection.Find(filter)
.SortBy(x => x.PackId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return docs.Select(d => d.ToModel()).ToList();
}
private sealed class ParityDocument
{
public ObjectId Id { get; set; }
public string PackId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string Status { get; set; } = default!;
public string? Notes { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public ParityRecord ToModel() => new(PackId, TenantId, Status, Notes, UpdatedAtUtc);
public static ParityDocument From(ParityRecord record) => new()
{
PackId = record.PackId,
TenantId = record.TenantId,
Status = record.Status,
Notes = record.Notes,
UpdatedAtUtc = record.UpdatedAtUtc
};
}
}

View File

@@ -0,0 +1,109 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.PacksRegistry.Infrastructure.Options;
namespace StellaOps.PacksRegistry.Infrastructure.Mongo;
/// <summary>
/// Ensures Mongo collections and indexes exist for packs, blobs, and parity matrix.
/// </summary>
public sealed class PacksMongoInitializer : IHostedService
{
private readonly IMongoDatabase _database;
private readonly MongoOptions _options;
private readonly ILogger<PacksMongoInitializer> _logger;
public PacksMongoInitializer(IMongoDatabase database, MongoOptions options, ILogger<PacksMongoInitializer> logger)
{
_database = database ?? throw new ArgumentNullException(nameof(database));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await EnsurePacksIndexAsync(cancellationToken).ConfigureAwait(false);
await EnsureBlobsIndexAsync(cancellationToken).ConfigureAwait(false);
await EnsureParityMatrixAsync(cancellationToken).ConfigureAwait(false);
await EnsureLifecycleAsync(cancellationToken).ConfigureAwait(false);
await EnsureAuditAsync(cancellationToken).ConfigureAwait(false);
await EnsureAttestationsAsync(cancellationToken).ConfigureAwait(false);
await EnsureMirrorsAsync(cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task EnsurePacksIndexAsync(CancellationToken cancellationToken)
{
var packs = _database.GetCollection<BsonDocument>(_options.PacksCollection);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
await packs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
var secondary = Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("name").Ascending("version");
await packs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(secondary), cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async Task EnsureBlobsIndexAsync(CancellationToken cancellationToken)
{
var blobs = _database.GetCollection<BsonDocument>(_options.BlobsCollection);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("digest");
await blobs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async Task EnsureParityMatrixAsync(CancellationToken cancellationToken)
{
var parityName = _options.ParityCollection ?? "packs_parity_matrix";
var parity = _database.GetCollection<BsonDocument>(parityName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
await parity.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo collections ensured: {Packs}, {Blobs}, {Parity}", _options.PacksCollection, _options.BlobsCollection, parityName);
}
private async Task EnsureLifecycleAsync(CancellationToken cancellationToken)
{
var lifecycleName = _options.LifecycleCollection ?? "packs_lifecycle";
var lifecycle = _database.GetCollection<BsonDocument>(lifecycleName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId");
await lifecycle.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo lifecycle collection ensured: {Lifecycle}", lifecycleName);
}
private async Task EnsureAuditAsync(CancellationToken cancellationToken)
{
var auditName = _options.AuditCollection ?? "packs_audit_log";
var audit = _database.GetCollection<BsonDocument>(auditName);
var indexKeys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("packId")
.Ascending("occurredAtUtc");
await audit.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo audit collection ensured: {Audit}", auditName);
}
private async Task EnsureAttestationsAsync(CancellationToken cancellationToken)
{
var attestName = _options.AttestationCollection ?? "packs_attestations";
var attest = _database.GetCollection<BsonDocument>(attestName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("packId").Ascending("type");
await attest.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
var blobsName = _options.AttestationBlobsCollection ?? "packs_attestation_blobs";
var blobs = _database.GetCollection<BsonDocument>(blobsName);
var blobIndex = Builders<BsonDocument>.IndexKeys.Ascending("digest");
await blobs.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(blobIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo attestation collections ensured: {Attest} / {AttestBlobs}", attestName, blobsName);
}
private async Task EnsureMirrorsAsync(CancellationToken cancellationToken)
{
var mirrorName = _options.MirrorCollection ?? "packs_mirrors";
var mirrors = _database.GetCollection<BsonDocument>(mirrorName);
var indexKeys = Builders<BsonDocument>.IndexKeys.Ascending("id");
await mirrors.Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(indexKeys, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Mongo mirror collection ensured: {Mirror}", mirrorName);
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.PacksRegistry.Infrastructure.Options;
public sealed class MongoOptions
{
public string? ConnectionString { get; set; }
public string Database { get; set; } = "packs_registry";
public string PacksCollection { get; set; } = "packs_index";
public string BlobsCollection { get; set; } = "packs_blobs";
public string? ParityCollection { get; set; } = "packs_parity_matrix";
public string? LifecycleCollection { get; set; } = "packs_lifecycle";
public string? AuditCollection { get; set; } = "packs_audit_log";
public string? AttestationCollection { get; set; } = "packs_attestations";
public string? AttestationBlobsCollection { get; set; } = "packs_attestation_blobs";
public string? MirrorCollection { get; set; } = "packs_mirrors";
}

View File

@@ -3,14 +3,22 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<PropertyGroup>

View File

@@ -0,0 +1,50 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Core.Contracts;
namespace StellaOps.PacksRegistry.Infrastructure.Verification;
/// <summary>
/// Verifies signatures over the digest string using an RSA public key (PEM, PKCS#8 or PKCS#1), SHA-256.
/// Signature input is expected to be base64 of the raw RSA signature over UTF-8 digest text (e.g. "sha256:abcd...").
/// </summary>
public sealed class RsaSignatureVerifier : IPackSignatureVerifier
{
private readonly RSA _rsa;
public RsaSignatureVerifier(string publicKeyPem)
{
if (string.IsNullOrWhiteSpace(publicKeyPem))
{
throw new ArgumentException("Public key PEM is required for RSA verification.", nameof(publicKeyPem));
}
_rsa = RSA.Create();
_rsa.ImportFromPem(publicKeyPem);
}
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
if (string.IsNullOrWhiteSpace(signature))
{
return Task.FromResult(false);
}
byte[] sigBytes;
try
{
sigBytes = Convert.FromBase64String(signature);
}
catch
{
return Task.FromResult(false);
}
var data = Encoding.UTF8.GetBytes(digest);
var valid = _rsa.VerifyData(data, sigBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return Task.FromResult(valid);
}
}

View File

@@ -0,0 +1,46 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Core.Contracts;
namespace StellaOps.PacksRegistry.Infrastructure.Verification;
/// <summary>
/// Accepts either no signature or a signature that matches the computed SHA-256 digest (hex or base64 of digest string).
/// Replace with real signature verification when keys/attestations are available.
/// </summary>
public sealed class SimpleSignatureVerifier : IPackSignatureVerifier
{
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
using var sha = SHA256.Create();
var computed = "sha256:" + Convert.ToHexString(sha.ComputeHash(content)).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(signature))
{
return Task.FromResult(string.Equals(computed, digest, StringComparison.OrdinalIgnoreCase));
}
if (string.Equals(signature, computed, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(true);
}
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(signature));
if (string.Equals(decoded, computed, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(true);
}
}
catch
{
// ignore decode errors
}
return Task.FromResult(false);
}
}

View File

@@ -0,0 +1,41 @@
using System.IO.Compression;
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.Infrastructure.Verification;
namespace StellaOps.PacksRegistry.Tests;
public sealed class ExportServiceTests
{
[Fact]
public async Task Offline_seed_includes_metadata_and_content_when_requested()
{
var ct = TestContext.Current.CancellationToken;
var packRepo = new InMemoryPackRepository();
var parityRepo = new InMemoryParityRepository();
var lifecycleRepo = new InMemoryLifecycleRepository();
var auditRepo = new InMemoryAuditRepository();
var verifier = new SimpleSignatureVerifier();
var packService = new PackService(packRepo, verifier, auditRepo, null, TimeProvider.System);
var parityService = new ParityService(parityRepo, packRepo, auditRepo, TimeProvider.System);
var lifecycleService = new LifecycleService(lifecycleRepo, packRepo, auditRepo, TimeProvider.System);
var exportService = new ExportService(packRepo, parityRepo, lifecycleRepo, auditRepo, TimeProvider.System);
var content = System.Text.Encoding.UTF8.GetBytes("export-pack");
var provenance = System.Text.Encoding.UTF8.GetBytes("{\"p\":1}");
var record = await packService.UploadAsync("demo", "1.2.3", "tenant-1", content, null, null, provenance, null, ct);
await parityService.SetStatusAsync(record.PackId, record.TenantId, "ready", "seed", ct);
await lifecycleService.SetStateAsync(record.PackId, record.TenantId, "promoted", "seed", ct);
var archiveStream = await exportService.ExportOfflineSeedAsync(record.TenantId, includeContent: true, includeProvenance: true, cancellationToken: ct);
using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read);
Assert.NotNull(archive.GetEntry("packs.ndjson"));
Assert.NotNull(archive.GetEntry("parity.ndjson"));
Assert.NotNull(archive.GetEntry("lifecycle.ndjson"));
Assert.NotNull(archive.GetEntry("audit.ndjson"));
Assert.NotNull(archive.GetEntry($"content/{record.PackId}.bin"));
Assert.NotNull(archive.GetEntry($"provenance/{record.PackId}.json"));
}
}

View File

@@ -0,0 +1,45 @@
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
namespace StellaOps.PacksRegistry.Tests;
public sealed class FilePackRepositoryTests
{
[Fact]
public async Task Upsert_and_List_round_trip()
{
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempPath);
try
{
var ct = TestContext.Current.CancellationToken;
var repo = new FilePackRepository(tempPath);
var record = new PackRecord(
PackId: "demo@1.0.0",
Name: "demo",
Version: "1.0.0",
TenantId: "t1",
Digest: "sha256:abc",
Signature: null,
ProvenanceUri: null,
ProvenanceDigest: null,
CreatedAtUtc: DateTimeOffset.Parse("2025-11-24T00:00:00Z"),
Metadata: new Dictionary<string, string> { ["lang"] = "csharp" });
await repo.UpsertAsync(record, new byte[] { 1, 2, 3 }, null, ct);
var listed = await repo.ListAsync("t1", ct);
Assert.Single(listed);
Assert.Equal(record.PackId, listed[0].PackId);
var fetched = await repo.GetAsync("demo@1.0.0", ct);
Assert.NotNull(fetched);
Assert.Equal(record.Digest, fetched!.Digest);
}
finally
{
Directory.Delete(tempPath, recursive: true);
}
}
}

View File

@@ -0,0 +1,95 @@
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.Infrastructure.Verification;
namespace StellaOps.PacksRegistry.Tests;
public sealed class PackServiceTests
{
private static byte[] SampleContent => System.Text.Encoding.UTF8.GetBytes("sample-pack-content");
[Fact]
public async Task Upload_persists_pack_with_digest()
{
var ct = TestContext.Current.CancellationToken;
var repo = new InMemoryPackRepository();
var verifier = new SimpleSignatureVerifier();
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
var record = await service.UploadAsync(
name: "demo-pack",
version: "1.0.0",
tenantId: "tenant-1",
content: SampleContent,
signature: null,
provenanceUri: "https://example/manifest.json",
provenanceContent: null,
metadata: new Dictionary<string, string> { ["lang"] = "csharp" },
cancellationToken: ct);
Assert.Equal("demo-pack@1.0.0", record.PackId);
Assert.NotNull(record.Digest);
var listed = await service.ListAsync("tenant-1", ct);
Assert.Single(listed);
Assert.Equal(record.PackId, listed[0].PackId);
}
[Fact]
public async Task Upload_rejects_when_digest_mismatch()
{
var ct = TestContext.Current.CancellationToken;
var repo = new InMemoryPackRepository();
var verifier = new AlwaysFailSignatureVerifier();
var service = new PackService(repo, verifier, new InMemoryAuditRepository(), null, TimeProvider.System);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.UploadAsync(
name: "demo-pack",
version: "1.0.0",
tenantId: "tenant-1",
content: SampleContent,
signature: "bogus",
provenanceUri: null,
provenanceContent: null,
metadata: null,
cancellationToken: ct));
}
[Fact]
public async Task Rotate_signature_updates_record_and_audits()
{
var ct = TestContext.Current.CancellationToken;
var repo = new InMemoryPackRepository();
var audit = new InMemoryAuditRepository();
var verifier = new SimpleSignatureVerifier();
var service = new PackService(repo, verifier, audit, null, TimeProvider.System);
var record = await service.UploadAsync(
name: "demo-pack",
version: "1.0.0",
tenantId: "tenant-1",
content: SampleContent,
signature: null,
provenanceUri: null,
provenanceContent: null,
metadata: null,
cancellationToken: ct);
var digest = record.Digest;
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(digest));
var rotated = await service.RotateSignatureAsync(record.PackId, record.TenantId, newSignature, cancellationToken: ct);
Assert.Equal(newSignature, rotated.Signature);
var auditEvents = await audit.ListAsync(record.TenantId, ct);
Assert.Contains(auditEvents, a => a.Event == "signature.rotated" && a.PackId == record.PackId);
}
private sealed class AlwaysFailSignatureVerifier : StellaOps.PacksRegistry.Core.Contracts.IPackSignatureVerifier
{
public Task<bool> VerifyAsync(byte[] content, string digest, string? signature, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
}

View File

@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Json;
using System.IO.Compression;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.InMemory;
using StellaOps.PacksRegistry.WebService.Contracts;
namespace StellaOps.PacksRegistry.Tests;
public sealed class PacksApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public PacksApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IPackRepository>();
services.RemoveAll<IParityRepository>();
services.RemoveAll<ILifecycleRepository>();
services.RemoveAll<IAuditRepository>();
services.AddSingleton<IPackRepository, InMemoryPackRepository>();
services.AddSingleton<IParityRepository, InMemoryParityRepository>();
services.AddSingleton<ILifecycleRepository, InMemoryLifecycleRepository>();
services.AddSingleton<IAuditRepository, InMemoryAuditRepository>();
services.AddSingleton(TimeProvider.System);
services.RemoveAll<PackService>();
services.RemoveAll<ParityService>();
services.RemoveAll<LifecycleService>();
services.RemoveAll<ExportService>();
services.AddSingleton<PackService>();
services.AddSingleton<ParityService>();
services.AddSingleton<LifecycleService>();
services.AddSingleton<ExportService>();
});
});
}
[Fact]
public async Task Upload_and_download_round_trip()
{
var ct = TestContext.Current.CancellationToken;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "t1");
var auth = _factory.Services.GetRequiredService<StellaOps.PacksRegistry.WebService.Options.AuthOptions>();
if (!string.IsNullOrWhiteSpace(auth.ApiKey))
{
client.DefaultRequestHeaders.Add("X-API-Key", auth.ApiKey);
}
var payload = new PackUploadRequest
{
Name = "demo",
Version = "1.0.0",
TenantId = "t1",
Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello")),
ProvenanceUri = "https://example/provenance.json",
ProvenanceContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"provenance\":true}"))
};
var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/packs")
{
Content = JsonContent.Create(payload)
};
var response = await client.SendAsync(message, ct);
if (response.StatusCode != HttpStatusCode.Created)
{
var body = await response.Content.ReadAsStringAsync(ct);
throw new InvalidOperationException($"Upload failed with {response.StatusCode}: {body}");
}
var created = await response.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
Assert.NotNull(created);
Assert.Equal("demo", created!.Name);
Assert.Equal("1.0.0", created.Version);
var get = await client.GetAsync($"/api/v1/packs/{created.PackId}", ct);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
var content = await client.GetAsync($"/api/v1/packs/{created.PackId}/content", ct);
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
var bytes = await content.Content.ReadAsByteArrayAsync(ct);
Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(bytes));
Assert.True(content.Headers.Contains("X-Content-Digest"));
var prov = await client.GetAsync($"/api/v1/packs/{created.PackId}/provenance", ct);
Assert.Equal(HttpStatusCode.OK, prov.StatusCode);
var provBytes = await prov.Content.ReadAsByteArrayAsync(ct);
Assert.Contains("provenance", System.Text.Encoding.UTF8.GetString(provBytes));
Assert.True(prov.Headers.Contains("X-Provenance-Digest"));
var manifest = await client.GetFromJsonAsync<PackManifestResponse>($"/api/v1/packs/{created.PackId}/manifest", ct);
Assert.NotNull(manifest);
Assert.Equal(created.PackId, manifest!.PackId);
Assert.True(manifest.ContentLength > 0);
Assert.True(manifest.ProvenanceLength > 0);
// parity status
var parityResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/parity", new ParityRequest { Status = "ready", Notes = "tests" }, ct);
Assert.Equal(HttpStatusCode.OK, parityResponse.StatusCode);
var parity = await client.GetFromJsonAsync<ParityResponse>($"/api/v1/packs/{created.PackId}/parity", ct);
Assert.NotNull(parity);
Assert.Equal("ready", parity!.Status);
var newSignature = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(created.Digest));
var rotationResponse = await client.PostAsJsonAsync($"/api/v1/packs/{created.PackId}/signature", new RotateSignatureRequest { Signature = newSignature }, ct);
Assert.Equal(HttpStatusCode.OK, rotationResponse.StatusCode);
var rotated = await rotationResponse.Content.ReadFromJsonAsync<PackResponse>(cancellationToken: ct);
Assert.Equal(newSignature, rotated!.Signature);
var offlineSeed = await client.PostAsJsonAsync("/api/v1/export/offline-seed", new OfflineSeedRequest { TenantId = "t1", IncludeContent = true, IncludeProvenance = true }, ct);
Assert.Equal(HttpStatusCode.OK, offlineSeed.StatusCode);
var bytesZip = await offlineSeed.Content.ReadAsByteArrayAsync(ct);
using var archive = new ZipArchive(new MemoryStream(bytesZip));
Assert.NotNull(archive.GetEntry("packs.ndjson"));
Assert.NotNull(archive.GetEntry($"content/{created.PackId}.bin"));
}
}

View File

@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.PacksRegistry.Infrastructure.Verification;
namespace StellaOps.PacksRegistry.Tests;
public sealed class RsaSignatureVerifierTests
{
[Fact]
public async Task Verify_succeeds_when_signature_matches_digest()
{
var ct = TestContext.Current.CancellationToken;
using var rsa = RSA.Create(2048);
var publicPem = ExportPublicPem(rsa);
const string digest = "sha256:deadbeef";
var sig = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(digest), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var verifier = new RsaSignatureVerifier(publicPem);
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
Assert.True(ok);
}
[Fact]
public async Task Verify_fails_on_invalid_signature()
{
var ct = TestContext.Current.CancellationToken;
using var rsa = RSA.Create(2048);
var publicPem = ExportPublicPem(rsa);
const string digest = "sha256:deadbeef";
var sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("bogus"));
var verifier = new RsaSignatureVerifier(publicPem);
var ok = await verifier.VerifyAsync(Array.Empty<byte>(), digest, sig, ct);
Assert.False(ok);
}
private static string ExportPublicPem(RSA rsa)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
builder.AppendLine(Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo(), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END PUBLIC KEY-----");
return builder.ToString();
}
}

View File

@@ -1,135 +1,34 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit.v3" Version="3.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj"/>
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj"/>
</ItemGroup>
</Project>
<?xml version="1.0"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.PacksRegistry.Core\StellaOps.PacksRegistry.Core.csproj" />
<ProjectReference Include="..\StellaOps.PacksRegistry.Infrastructure\StellaOps.PacksRegistry.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.PacksRegistry.WebService\StellaOps.PacksRegistry.WebService.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
namespace StellaOps.PacksRegistry.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,9 @@
using StellaOps.PacksRegistry.Core.Models;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record AttestationResponse(string PackId, string Type, string Digest, DateTimeOffset CreatedAtUtc, string? Notes)
{
public static AttestationResponse From(AttestationRecord record) => new(record.PackId, record.Type, record.Digest, record.CreatedAtUtc, record.Notes);
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed class AttestationUploadRequest
{
public string? Type { get; set; }
public string? Content { get; set; }
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,9 @@
using StellaOps.PacksRegistry.Core.Services;
namespace StellaOps.PacksRegistry.WebService.Contracts;
public sealed record ComplianceSummaryResponse(int TotalPacks, int UnsignedPacks, int PromotedPacks, int DeprecatedPacks, int ParityReadyPacks)
{
public static ComplianceSummaryResponse From(ComplianceSummary summary) => new(summary.TotalPacks, summary.UnsignedPacks, summary.PromotedPacks, summary.DeprecatedPacks, summary.ParityReadyPacks);
}

Some files were not shown because too many files have changed in this diff Show More