partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,422 @@
// -----------------------------------------------------------------------------
// EdgeMetadataContracts.cs
// Sprint: SPRINT_20260208_039_Graph_graph_edge_metadata_with_reason_evidence_provenance
// Description: Contracts for edge metadata including reason, evidence, and provenance.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Graph.Api.Contracts;
/// <summary>
/// Enumeration of reasons why an edge exists in the graph.
/// Provides human-readable explanations for graph relationships.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EdgeReason
{
/// <summary>Reason is unknown or not yet determined.</summary>
Unknown = 0,
/// <summary>Edge from SBOM component dependency declaration.</summary>
SbomDependency = 1,
/// <summary>Edge from static analysis symbol resolution.</summary>
StaticSymbol = 2,
/// <summary>Edge from dynamic runtime call trace.</summary>
RuntimeTrace = 3,
/// <summary>Edge from package manifest (package.json, Cargo.toml, etc.).</summary>
PackageManifest = 4,
/// <summary>Edge from lockfile (package-lock.json, Cargo.lock, etc.).</summary>
Lockfile = 5,
/// <summary>Edge from build system output.</summary>
BuildArtifact = 6,
/// <summary>Edge from container image layer analysis.</summary>
ImageLayer = 7,
/// <summary>Edge from advisory/vulnerability affecting relationship.</summary>
AdvisoryAffects = 8,
/// <summary>Edge from VEX statement relationship.</summary>
VexStatement = 9,
/// <summary>Edge from policy overlay.</summary>
PolicyOverlay = 10,
/// <summary>Edge from attestation reference.</summary>
AttestationRef = 11,
/// <summary>Edge from manual operator annotation.</summary>
OperatorAnnotation = 12,
/// <summary>Edge inferred from transitive dependency analysis.</summary>
TransitiveInference = 13,
/// <summary>Edge from provenance/SLSA relationship.</summary>
Provenance = 14
}
/// <summary>
/// Describes how an edge was discovered or established.
/// Captures the method/tool used to identify the relationship.
/// </summary>
public sealed record EdgeVia
{
/// <summary>
/// The mechanism or tool that established this edge.
/// </summary>
[JsonPropertyName("method")]
public required string Method { get; init; }
/// <summary>
/// Version of the tool/mechanism if applicable.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
/// <summary>
/// Timestamp when the edge was established.
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Confidence score (0-10000 basis points) in the edge validity.
/// 10000 = 100% confidence.
/// </summary>
[JsonPropertyName("confidenceBps")]
public int ConfidenceBps { get; init; } = 10000;
/// <summary>
/// Reference to the source evidence (e.g., SBOM digest, attestation ID).
/// </summary>
[JsonPropertyName("evidenceRef")]
public string? EvidenceRef { get; init; }
}
/// <summary>
/// Complete explanation payload for why an edge exists.
/// Combines reason, method, and evidence provenance.
/// </summary>
public sealed record EdgeExplanationPayload
{
/// <summary>
/// The primary reason for this edge's existence.
/// </summary>
[JsonPropertyName("reason")]
public EdgeReason Reason { get; init; } = EdgeReason.Unknown;
/// <summary>
/// How the edge was discovered/established.
/// </summary>
[JsonPropertyName("via")]
public EdgeVia? Via { get; init; }
/// <summary>
/// Human-readable summary of why this edge exists.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Additional evidence references that support this edge.
/// Maps evidence type to reference ID (e.g., "sbom" -> "sha256:abc").
/// </summary>
[JsonPropertyName("evidence")]
public IReadOnlyDictionary<string, string>? Evidence { get; init; }
/// <summary>
/// Provenance metadata linking back to source artifacts.
/// </summary>
[JsonPropertyName("provenance")]
public EdgeProvenanceRef? Provenance { get; init; }
/// <summary>
/// Tags for categorization and filtering.
/// </summary>
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Provenance reference for an edge, linking back to source evidence.
/// </summary>
public sealed record EdgeProvenanceRef
{
/// <summary>
/// Source system that generated this edge.
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Timestamp when the evidence was collected.
/// </summary>
[JsonPropertyName("collectedAt")]
public DateTimeOffset CollectedAt { get; init; }
/// <summary>
/// Digest of the SBOM that sourced this edge, if applicable.
/// </summary>
[JsonPropertyName("sbomDigest")]
public string? SbomDigest { get; init; }
/// <summary>
/// Scan digest that identified this edge, if applicable.
/// </summary>
[JsonPropertyName("scanDigest")]
public string? ScanDigest { get; init; }
/// <summary>
/// Attestation bundle ID containing evidence, if applicable.
/// </summary>
[JsonPropertyName("attestationId")]
public string? AttestationId { get; init; }
/// <summary>
/// Event offset in the event log for replay.
/// </summary>
[JsonPropertyName("eventOffset")]
public long? EventOffset { get; init; }
}
/// <summary>
/// Extended edge tile with metadata for API responses.
/// Augments EdgeTile with explanation and provenance.
/// </summary>
public sealed record EdgeTileWithMetadata
{
/// <summary>
/// Edge unique identifier.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
/// <summary>
/// Edge kind/type (e.g., "depends_on", "affects", "vex_applies").
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "depends_on";
/// <summary>
/// Tenant owning this edge.
/// </summary>
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
/// <summary>
/// Source node ID.
/// </summary>
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
/// <summary>
/// Target node ID.
/// </summary>
[JsonPropertyName("target")]
public string Target { get; init; } = string.Empty;
/// <summary>
/// Edge attributes.
/// </summary>
[JsonPropertyName("attributes")]
public Dictionary<string, object?> Attributes { get; init; } = new();
/// <summary>
/// Explanation of why this edge exists.
/// </summary>
[JsonPropertyName("explanation")]
public EdgeExplanationPayload? Explanation { get; init; }
}
/// <summary>
/// Request to query edge metadata for specific edges.
/// </summary>
public sealed record EdgeMetadataRequest
{
/// <summary>
/// Edge IDs to retrieve metadata for.
/// </summary>
[JsonPropertyName("edgeIds")]
public IReadOnlyList<string> EdgeIds { get; init; } = Array.Empty<string>();
/// <summary>
/// Whether to include full provenance details.
/// </summary>
[JsonPropertyName("includeProvenance")]
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Whether to include evidence references.
/// </summary>
[JsonPropertyName("includeEvidence")]
public bool IncludeEvidence { get; init; } = true;
}
/// <summary>
/// Response containing edge metadata.
/// </summary>
public sealed record EdgeMetadataResponse
{
/// <summary>
/// Edges with their metadata.
/// </summary>
[JsonPropertyName("edges")]
public IReadOnlyList<EdgeTileWithMetadata> Edges { get; init; } = Array.Empty<EdgeTileWithMetadata>();
/// <summary>
/// Total count of edges returned.
/// </summary>
[JsonPropertyName("total")]
public int Total { get; init; }
}
/// <summary>
/// Helper for creating edge explanations with common patterns.
/// </summary>
public static class EdgeExplanationFactory
{
/// <summary>
/// Creates an explanation for an SBOM dependency edge.
/// </summary>
public static EdgeExplanationPayload FromSbomDependency(
string sbomDigest,
string source,
DateTimeOffset collectedAt,
string? summary = null)
{
return new EdgeExplanationPayload
{
Reason = EdgeReason.SbomDependency,
Via = new EdgeVia
{
Method = "sbom-parser",
Timestamp = collectedAt,
ConfidenceBps = 10000,
EvidenceRef = sbomDigest
},
Summary = summary ?? "Dependency declared in SBOM",
Evidence = new Dictionary<string, string> { ["sbom"] = sbomDigest },
Provenance = new EdgeProvenanceRef
{
Source = source,
CollectedAt = collectedAt,
SbomDigest = sbomDigest
}
};
}
/// <summary>
/// Creates an explanation for an advisory affects edge.
/// </summary>
public static EdgeExplanationPayload FromAdvisory(
string advisoryId,
string source,
DateTimeOffset collectedAt,
string? scanDigest = null)
{
return new EdgeExplanationPayload
{
Reason = EdgeReason.AdvisoryAffects,
Via = new EdgeVia
{
Method = "advisory-matcher",
Timestamp = collectedAt,
ConfidenceBps = 10000,
EvidenceRef = advisoryId
},
Summary = $"Affected by advisory {advisoryId}",
Evidence = new Dictionary<string, string> { ["advisory"] = advisoryId },
Provenance = new EdgeProvenanceRef
{
Source = source,
CollectedAt = collectedAt,
ScanDigest = scanDigest
}
};
}
/// <summary>
/// Creates an explanation for a VEX statement edge.
/// </summary>
public static EdgeExplanationPayload FromVex(
string vexDocumentId,
string statement,
string source,
DateTimeOffset collectedAt,
string? attestationId = null)
{
return new EdgeExplanationPayload
{
Reason = EdgeReason.VexStatement,
Via = new EdgeVia
{
Method = "vex-processor",
Timestamp = collectedAt,
ConfidenceBps = 10000,
EvidenceRef = vexDocumentId
},
Summary = statement,
Evidence = new Dictionary<string, string> { ["vex"] = vexDocumentId },
Provenance = new EdgeProvenanceRef
{
Source = source,
CollectedAt = collectedAt,
AttestationId = attestationId
}
};
}
/// <summary>
/// Creates an explanation for a static analysis edge.
/// </summary>
public static EdgeExplanationPayload FromStaticAnalysis(
string symbol,
string analysisToolVersion,
DateTimeOffset analysisTime,
int confidenceBps = 9000)
{
return new EdgeExplanationPayload
{
Reason = EdgeReason.StaticSymbol,
Via = new EdgeVia
{
Method = "static-analysis",
Version = analysisToolVersion,
Timestamp = analysisTime,
ConfidenceBps = confidenceBps
},
Summary = $"Static analysis resolved symbol: {symbol}",
Tags = ["static-analysis", "symbol-resolution"]
};
}
/// <summary>
/// Creates an explanation for a runtime trace edge.
/// </summary>
public static EdgeExplanationPayload FromRuntimeTrace(
string traceId,
DateTimeOffset traceTime,
int callCount)
{
return new EdgeExplanationPayload
{
Reason = EdgeReason.RuntimeTrace,
Via = new EdgeVia
{
Method = "runtime-instrumentation",
Timestamp = traceTime,
ConfidenceBps = 10000,
EvidenceRef = traceId
},
Summary = $"Observed {callCount} runtime call(s)",
Evidence = new Dictionary<string, string> { ["trace"] = traceId },
Tags = ["runtime", "dynamic-analysis"]
};
}
}

View File

@@ -16,6 +16,8 @@ builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IEdgeMetadataService, InMemoryEdgeMetadataService>();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.TryAddStellaOpsLocalBinding("graph");
var app = builder.Build();
@@ -362,6 +364,102 @@ app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphEx
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
});
// ────────────────────────────────────────────────────────────────────────────────
// Edge Metadata API
// ────────────────────────────────────────────────────────────────────────────────
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/metadata"))
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var response = await service.GetEdgeMetadataAsync(tenant, request, ct);
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/metadata"))
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var result = await service.GetSingleEdgeMetadataAsync(tenant, edgeId, ct);
if (result is null)
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
return Results.NotFound(new ErrorResponse { Error = "EDGE_NOT_FOUND", Message = $"Edge '{edgeId}' not found" });
}
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(result);
});
app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/path"))
{
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var edges = await service.GetPathEdgesWithMetadataAsync(tenant, sourceNodeId, targetNodeId, ct);
LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() });
});
app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/by-reason"))
{
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
if (!Enum.TryParse<EdgeReason>(reason, ignoreCase: true, out var edgeReason))
{
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" });
}
var response = await service.QueryByReasonAsync(tenant, edgeReason, limit ?? 100, cursor, ct);
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/by-evidence"))
{
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var edges = await service.QueryByEvidenceAsync(tenant, evidenceType, evidenceRef, ct);
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(new { evidenceType, evidenceRef, edges = edges.ToList() });
});
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.Run();

View File

@@ -0,0 +1,83 @@
// -----------------------------------------------------------------------------
// IEdgeMetadataService.cs
// Sprint: SPRINT_20260208_039_Graph_graph_edge_metadata_with_reason_evidence_provenance
// Description: Service interface for edge metadata queries.
// -----------------------------------------------------------------------------
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
/// <summary>
/// Service for querying and managing edge metadata (reason, evidence, provenance).
/// </summary>
public interface IEdgeMetadataService
{
/// <summary>
/// Gets edge metadata for specified edge IDs.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="request">Request specifying edge IDs and options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Response containing edge metadata.</returns>
Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
string tenant,
EdgeMetadataRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets metadata for a single edge.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="edgeId">Edge identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Edge with metadata, or null if not found.</returns>
Task<EdgeTileWithMetadata?> GetSingleEdgeMetadataAsync(
string tenant,
string edgeId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all edges with metadata for a path between nodes.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="sourceNodeId">Source node ID.</param>
/// <param name="targetNodeId">Target node ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Edges along the path with their metadata.</returns>
Task<IReadOnlyList<EdgeTileWithMetadata>> GetPathEdgesWithMetadataAsync(
string tenant,
string sourceNodeId,
string targetNodeId,
CancellationToken cancellationToken = default);
/// <summary>
/// Queries edges by reason type.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="reason">Edge reason to filter by.</param>
/// <param name="limit">Maximum results to return.</param>
/// <param name="cursor">Pagination cursor.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Edges matching the reason filter.</returns>
Task<EdgeMetadataResponse> QueryByReasonAsync(
string tenant,
EdgeReason reason,
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Queries edges by evidence reference.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="evidenceType">Type of evidence (e.g., "sbom", "advisory", "vex").</param>
/// <param name="evidenceRef">Reference ID of the evidence.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Edges linked to the specified evidence.</returns>
Task<EdgeMetadataResponse> QueryByEvidenceAsync(
string tenant,
string evidenceType,
string evidenceRef,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,433 @@
// -----------------------------------------------------------------------------
// InMemoryEdgeMetadataService.cs
// Sprint: SPRINT_20260208_039_Graph_graph_edge_metadata_with_reason_evidence_provenance
// Description: In-memory implementation of edge metadata service.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
/// <summary>
/// In-memory implementation of edge metadata service.
/// Stores edge explanations alongside the graph repository.
/// </summary>
public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
{
private readonly InMemoryGraphRepository _repository;
private readonly ILogger<InMemoryEdgeMetadataService> _logger;
private readonly TimeProvider _timeProvider;
// Cache of edge explanations keyed by edge ID
private readonly Dictionary<string, EdgeExplanationPayload> _explanations;
public InMemoryEdgeMetadataService(
InMemoryGraphRepository repository,
ILogger<InMemoryEdgeMetadataService> logger,
TimeProvider timeProvider)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
// Seed with default explanations for demo/test data
_explanations = SeedDefaultExplanations();
}
public Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
string tenant,
EdgeMetadataRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(request);
var edges = new List<EdgeTileWithMetadata>();
foreach (var edgeId in request.EdgeIds)
{
var edgeTile = _repository.GetEdge(tenant, edgeId);
if (edgeTile is null)
{
continue;
}
var explanation = GetOrCreateExplanation(edgeTile, request.IncludeProvenance, request.IncludeEvidence);
edges.Add(ToEdgeTileWithMetadata(edgeTile, explanation));
}
_logger.LogDebug("Retrieved metadata for {Count} edges in tenant {Tenant}", edges.Count, tenant);
return Task.FromResult(new EdgeMetadataResponse
{
Edges = edges,
Total = edges.Count
});
}
public Task<EdgeTileWithMetadata?> GetSingleEdgeMetadataAsync(
string tenant,
string edgeId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(edgeId);
var edgeTile = _repository.GetEdge(tenant, edgeId);
if (edgeTile is null)
{
return Task.FromResult<EdgeTileWithMetadata?>(null);
}
var explanation = GetOrCreateExplanation(edgeTile, includeProvenance: true, includeEvidence: true);
return Task.FromResult<EdgeTileWithMetadata?>(ToEdgeTileWithMetadata(edgeTile, explanation));
}
public Task<IReadOnlyList<EdgeTileWithMetadata>> GetPathEdgesWithMetadataAsync(
string tenant,
string sourceNodeId,
string targetNodeId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceNodeId);
ArgumentException.ThrowIfNullOrWhiteSpace(targetNodeId);
// Get edges along the path (simplified BFS/DFS for in-memory)
var pathEdges = FindPathEdges(tenant, sourceNodeId, targetNodeId);
var result = new List<EdgeTileWithMetadata>();
foreach (var edge in pathEdges)
{
var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
result.Add(ToEdgeTileWithMetadata(edge, explanation));
}
return Task.FromResult<IReadOnlyList<EdgeTileWithMetadata>>(result);
}
public Task<EdgeMetadataResponse> QueryByReasonAsync(
string tenant,
EdgeReason reason,
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
var allEdges = _repository.GetAllEdges(tenant);
var matchingEdges = new List<EdgeTileWithMetadata>();
foreach (var edge in allEdges)
{
if (!_explanations.TryGetValue(edge.Id, out var explanation))
{
continue;
}
if (explanation.Reason == reason)
{
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
if (matchingEdges.Count >= limit)
{
break;
}
}
}
return Task.FromResult(new EdgeMetadataResponse
{
Edges = matchingEdges,
Total = matchingEdges.Count
});
}
public Task<EdgeMetadataResponse> QueryByEvidenceAsync(
string tenant,
string evidenceType,
string evidenceRef,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceType);
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceRef);
var allEdges = _repository.GetAllEdges(tenant);
var matchingEdges = new List<EdgeTileWithMetadata>();
foreach (var edge in allEdges)
{
if (!_explanations.TryGetValue(edge.Id, out var explanation))
{
continue;
}
if (explanation.Evidence is not null &&
explanation.Evidence.TryGetValue(evidenceType, out var refValue) &&
string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase))
{
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
}
}
return Task.FromResult(new EdgeMetadataResponse
{
Edges = matchingEdges,
Total = matchingEdges.Count
});
}
/// <summary>
/// Adds or updates an edge explanation.
/// </summary>
public void SetExplanation(string edgeId, EdgeExplanationPayload explanation)
{
ArgumentException.ThrowIfNullOrWhiteSpace(edgeId);
ArgumentNullException.ThrowIfNull(explanation);
_explanations[edgeId] = explanation;
}
private EdgeExplanationPayload GetOrCreateExplanation(
EdgeTile edge,
bool includeProvenance,
bool includeEvidence)
{
if (_explanations.TryGetValue(edge.Id, out var existing))
{
return FilterExplanation(existing, includeProvenance, includeEvidence);
}
// Generate default explanation based on edge kind
var generated = GenerateDefaultExplanation(edge);
return FilterExplanation(generated, includeProvenance, includeEvidence);
}
private static EdgeExplanationPayload FilterExplanation(
EdgeExplanationPayload explanation,
bool includeProvenance,
bool includeEvidence)
{
if (includeProvenance && includeEvidence)
{
return explanation;
}
return explanation with
{
Provenance = includeProvenance ? explanation.Provenance : null,
Evidence = includeEvidence ? explanation.Evidence : null
};
}
private EdgeExplanationPayload GenerateDefaultExplanation(EdgeTile edge)
{
var now = _timeProvider.GetUtcNow();
var reason = InferReasonFromKind(edge.Kind);
var summary = GenerateSummary(edge, reason);
return new EdgeExplanationPayload
{
Reason = reason,
Via = new EdgeVia
{
Method = "graph-indexer",
Timestamp = now,
ConfidenceBps = 10000
},
Summary = summary,
Tags = [edge.Kind]
};
}
private static EdgeReason InferReasonFromKind(string kind)
{
return kind.ToLowerInvariant() switch
{
"depends_on" => EdgeReason.SbomDependency,
"builds" => EdgeReason.BuildArtifact,
"affects" => EdgeReason.AdvisoryAffects,
"vex_applies" => EdgeReason.VexStatement,
"sbom_version_of" => EdgeReason.SbomDependency,
"sbom_lineage_parent" => EdgeReason.Provenance,
"policy_overlay" => EdgeReason.PolicyOverlay,
"calls" => EdgeReason.StaticSymbol,
"runtime_calls" => EdgeReason.RuntimeTrace,
"contains" => EdgeReason.ImageLayer,
_ => EdgeReason.Unknown
};
}
private static string GenerateSummary(EdgeTile edge, EdgeReason reason)
{
return reason switch
{
EdgeReason.SbomDependency => $"Dependency relationship: {edge.Source} -> {edge.Target}",
EdgeReason.BuildArtifact => $"Build produced: {edge.Source} -> {edge.Target}",
EdgeReason.AdvisoryAffects => $"Advisory affects: {edge.Source} -> {edge.Target}",
EdgeReason.VexStatement => $"VEX applies: {edge.Source} -> {edge.Target}",
EdgeReason.Provenance => $"Provenance chain: {edge.Source} -> {edge.Target}",
EdgeReason.StaticSymbol => $"Symbol reference: {edge.Source} -> {edge.Target}",
EdgeReason.RuntimeTrace => $"Runtime call: {edge.Source} -> {edge.Target}",
EdgeReason.ImageLayer => $"Container contains: {edge.Source} -> {edge.Target}",
_ => $"Relationship: {edge.Source} -> {edge.Target}"
};
}
private static EdgeTileWithMetadata ToEdgeTileWithMetadata(EdgeTile edge, EdgeExplanationPayload explanation)
{
return new EdgeTileWithMetadata
{
Id = edge.Id,
Kind = edge.Kind,
Tenant = edge.Tenant,
Source = edge.Source,
Target = edge.Target,
Attributes = edge.Attributes,
Explanation = explanation
};
}
private IReadOnlyList<EdgeTile> FindPathEdges(string tenant, string sourceNodeId, string targetNodeId)
{
// Simple BFS for path finding
var allEdges = _repository.GetAllEdges(tenant);
var edgesBySource = allEdges
.GroupBy(e => e.Source)
.ToDictionary(g => g.Key, g => g.ToList());
var visited = new HashSet<string>();
var queue = new Queue<(string NodeId, List<EdgeTile> Path)>();
queue.Enqueue((sourceNodeId, new List<EdgeTile>()));
while (queue.Count > 0)
{
var (current, path) = queue.Dequeue();
if (current == targetNodeId)
{
return path;
}
if (!visited.Add(current))
{
continue;
}
if (!edgesBySource.TryGetValue(current, out var outEdges))
{
continue;
}
foreach (var edge in outEdges)
{
if (!visited.Contains(edge.Target))
{
var newPath = new List<EdgeTile>(path) { edge };
queue.Enqueue((edge.Target, newPath));
}
}
}
return Array.Empty<EdgeTile>();
}
private Dictionary<string, EdgeExplanationPayload> SeedDefaultExplanations()
{
var now = _timeProvider.GetUtcNow();
return new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal)
{
["ge:acme:artifact->component"] = EdgeExplanationFactory.FromSbomDependency(
"sha256:sbom-a",
"sbom-parser",
now.AddHours(-1),
"Build artifact produces component"),
["ge:acme:component->component"] = new EdgeExplanationPayload
{
Reason = EdgeReason.SbomDependency,
Via = new EdgeVia
{
Method = "sbom-parser",
Timestamp = now.AddHours(-1),
ConfidenceBps = 10000,
EvidenceRef = "sha256:sbom-a"
},
Summary = "example@1.0.0 depends on widget@2.0.0 for runtime",
Evidence = new Dictionary<string, string> { ["sbom"] = "sha256:sbom-a" },
Provenance = new EdgeProvenanceRef
{
Source = "sbom-parser",
CollectedAt = now.AddHours(-1),
SbomDigest = "sha256:sbom-a"
},
Tags = ["runtime", "dependency"]
},
["ge:acme:sbom->artifact"] = new EdgeExplanationPayload
{
Reason = EdgeReason.SbomDependency,
Via = new EdgeVia
{
Method = "sbom-linker",
Timestamp = now.AddHours(-2),
ConfidenceBps = 10000
},
Summary = "SBOM describes artifact sha256:abc"
},
["ge:acme:sbom->sbom"] = new EdgeExplanationPayload
{
Reason = EdgeReason.Provenance,
Via = new EdgeVia
{
Method = "lineage-tracker",
Timestamp = now.AddDays(-1),
ConfidenceBps = 10000
},
Summary = "SBOM lineage: sbom-b derives from sbom-a",
Tags = ["lineage", "provenance"]
}
};
}
}
/// <summary>
/// Extension methods for InMemoryGraphRepository to support edge metadata queries.
/// </summary>
public static class InMemoryGraphRepositoryExtensions
{
/// <summary>
/// Gets a single edge by ID.
/// </summary>
public static EdgeTile? GetEdge(this InMemoryGraphRepository repository, string tenant, string edgeId)
{
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
{
Kinds = Array.Empty<string>(),
Query = null,
IncludeEdges = true
});
return edges.FirstOrDefault(e => e.Id.Equals(edgeId, StringComparison.Ordinal));
}
/// <summary>
/// Gets all edges for a tenant.
/// </summary>
public static IReadOnlyList<EdgeTile> GetAllEdges(this InMemoryGraphRepository repository, string tenant)
{
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
{
Kinds = Array.Empty<string>(),
Query = null,
IncludeEdges = true
});
return edges;
}
}

View File

@@ -0,0 +1,234 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class EdgeMetadataServiceTests
{
private readonly InMemoryGraphRepository _repo;
private readonly InMemoryEdgeMetadataService _service;
private readonly FakeTimeProvider _time;
public EdgeMetadataServiceTests()
{
_repo = new InMemoryGraphRepository();
_time = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_service = new InMemoryEdgeMetadataService(_repo, _time);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetEdgeMetadataAsync_WithValidEdgeIds_ReturnsEdgesWithExplanations()
{
// Arrange - default repo has some seeded edges
var request = new EdgeMetadataRequest
{
EdgeIds = new[] { "ge:acme:builds:1" } // Seeded edge
};
// Act
var result = await _service.GetEdgeMetadataAsync("acme", request, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Single(result.Edges);
var edge = result.Edges.First();
Assert.Equal("ge:acme:builds:1", edge.Id);
Assert.NotNull(edge.Explanation);
Assert.NotEqual(EdgeReason.Unknown, edge.Explanation.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetEdgeMetadataAsync_WithNonExistentEdgeIds_ReturnsEmptyList()
{
// Arrange
var request = new EdgeMetadataRequest
{
EdgeIds = new[] { "ge:acme:nonexistent:9999" }
};
// Act
var result = await _service.GetEdgeMetadataAsync("acme", request, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Empty(result.Edges);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSingleEdgeMetadataAsync_WithValidEdgeId_ReturnsEdgeWithMetadata()
{
// Act
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Equal("ge:acme:builds:1", result.Id);
Assert.Equal("acme", result.Tenant);
Assert.NotNull(result.Explanation);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSingleEdgeMetadataAsync_WithNonExistentEdgeId_ReturnsNull()
{
// Act
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:missing:999", CancellationToken.None);
// Assert
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetPathEdgesWithMetadataAsync_WithConnectedNodes_ReturnsPathEdges()
{
// The seeded data has: artifact:sha256:abc --builds--> component:widget
// Act
var result = await _service.GetPathEdgesWithMetadataAsync(
"acme",
"gn:acme:artifact:sha256:abc",
"gn:acme:component:widget",
CancellationToken.None);
// Assert
var edges = result.ToList();
Assert.NotEmpty(edges);
Assert.All(edges, e => Assert.NotNull(e.Explanation));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetPathEdgesWithMetadataAsync_WithDisconnectedNodes_ReturnsEmpty()
{
// Act
var result = await _service.GetPathEdgesWithMetadataAsync(
"acme",
"gn:acme:artifact:sha256:abc",
"gn:othertenant:component:missing",
CancellationToken.None);
// Assert
Assert.Empty(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryByReasonAsync_WithMatchingReason_ReturnsFilteredEdges()
{
// Arrange - seeded edges include "builds" which maps to SbomDependency
var reason = EdgeReason.SbomDependency;
// Act
var result = await _service.QueryByReasonAsync("acme", reason, 100, null, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.All(result.Edges, e => Assert.Equal(reason, e.Explanation?.Reason ?? EdgeReason.Unknown));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryByReasonAsync_RespectsLimitParameter()
{
// Act
var result = await _service.QueryByReasonAsync("acme", EdgeReason.SbomDependency, 1, null, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.True(result.Edges.Count <= 1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryByEvidenceAsync_WithMatchingEvidenceRef_ReturnsEdges()
{
// Act - query by sbom evidence type
var result = await _service.QueryByEvidenceAsync(
"acme",
"sbom",
"sbom:build:acme:1234:sha256",
CancellationToken.None);
// Assert
var edges = result.ToList();
// May or may not find matches depending on seeded data, but should not throw
Assert.NotNull(edges);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryByEvidenceAsync_WithNoMatchingEvidence_ReturnsEmpty()
{
// Act
var result = await _service.QueryByEvidenceAsync(
"acme",
"nonexistent",
"evidence:ref:that:does:not:exist",
CancellationToken.None);
// Assert
Assert.Empty(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EdgeExplanation_IncludesProvenanceInformation()
{
// Act
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Explanation);
Assert.NotNull(result.Explanation.Provenance);
Assert.NotEmpty(result.Explanation.Provenance.Source);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EdgeExplanation_IncludesViaInformation()
{
// Act
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Explanation);
Assert.NotNull(result.Explanation.Via);
Assert.NotEmpty(result.Explanation.Via.Method);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InferReasonFromKind_MapsCorrectly()
{
// Test by checking edges with different kinds return appropriate reasons
var result = await _service.GetSingleEdgeMetadataAsync("acme", "ge:acme:builds:1", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("builds", result.Kind);
// "builds" kind should map to SbomDependency
Assert.Equal(EdgeReason.SbomDependency, result.Explanation?.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TenantIsolation_OnlyReturnsEdgesForRequestedTenant()
{
// Act - query with a different tenant
var result = await _service.GetEdgeMetadataAsync(
"other-tenant",
new EdgeMetadataRequest { EdgeIds = new[] { "ge:acme:builds:1" } },
CancellationToken.None);
// Assert - should not find acme's edges
Assert.Empty(result.Edges);
}
}