From 285f761c773ca149fb837eb0a456d277e300440b Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 6 Apr 2026 08:52:37 +0300 Subject: [PATCH] Add Graph saved views persistence and compatibility endpoints Introduce PostgresGraphSavedViewStore with SQL migration, in-memory fallback, CompatibilityEndpoints for UI contract alignment, and integration tests with a shared Postgres fixture. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Endpoints/CompatibilityEndpoints.cs | 673 ++++++++++++++++++ .../Migrations/003_saved_views.sql | 15 + src/Graph/StellaOps.Graph.Api/Program.cs | 41 ++ .../Services/GraphSavedViewStore.cs | 35 + .../GraphSavedViewsMigrationHostedService.cs | 64 ++ .../Services/InMemoryGraphRepository.cs | 145 +++- .../Services/InMemoryGraphSavedViewStore.cs | 87 +++ .../Services/InMemoryOverlayService.cs | 58 +- .../Services/PostgresGraphSavedViewStore.cs | 182 +++++ .../StellaOps.Graph.Api.csproj | 2 + src/Graph/StellaOps.Graph.Api/TASKS.md | 1 + .../GraphApiPostgresFixture.cs | 13 + ...hCompatibilityEndpointsIntegrationTests.cs | 164 +++++ .../StellaOps.Graph.Api.Tests.csproj | 1 + 14 files changed, 1467 insertions(+), 14 deletions(-) create mode 100644 src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs create mode 100644 src/Graph/StellaOps.Graph.Api/Migrations/003_saved_views.sql create mode 100644 src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewStore.cs create mode 100644 src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewsMigrationHostedService.cs create mode 100644 src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSavedViewStore.cs create mode 100644 src/Graph/StellaOps.Graph.Api/Services/PostgresGraphSavedViewStore.cs create mode 100644 src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphApiPostgresFixture.cs create mode 100644 src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs diff --git a/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs b/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs new file mode 100644 index 000000000..34882c6bb --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs @@ -0,0 +1,673 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Graph.Api.Contracts; +using StellaOps.Graph.Api.Security; +using StellaOps.Graph.Api.Services; + +namespace StellaOps.Graph.Api.Endpoints; + +public static class CompatibilityEndpoints +{ + public static void MapCompatibilityEndpoints(this WebApplication app) + { + app.MapGet("/graphs", async Task ( + HttpContext context, + InMemoryGraphRepository repository, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + var nodes = repository.GetCompatibilityNodes(tenantId); + var edges = repository.GetCompatibilityEdges(tenantId); + var graphId = InMemoryGraphRepository.BuildGraphId(tenantId); + var snapshotAt = repository.GetSnapshotTimestamp(); + + return Results.Ok(new + { + items = new[] + { + new + { + graphId, + tenantId, + name = $"{tenantId.ToUpperInvariant()} Security Graph", + description = "Compatibility view over dependency, asset, and vulnerability relationships.", + status = "ready", + nodeCount = nodes.Count, + edgeCount = edges.Count, + snapshotAt, + createdAt = snapshotAt, + updatedAt = snapshotAt, + etag = $"\"{graphId}:v1\"" + } + }, + total = 1 + }); + }) + .RequireTenant(); + + app.MapGet("/graphs/{graphId}", async Task ( + string graphId, + HttpContext context, + InMemoryGraphRepository repository, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + var nodes = repository.GetCompatibilityNodes(tenantId); + var edges = repository.GetCompatibilityEdges(tenantId); + var snapshotAt = repository.GetSnapshotTimestamp(); + + return Results.Ok(new + { + graphId, + tenantId, + name = $"{tenantId.ToUpperInvariant()} Security Graph", + description = "Compatibility view over dependency, asset, and vulnerability relationships.", + status = "ready", + nodeCount = nodes.Count, + edgeCount = edges.Count, + snapshotAt, + createdAt = snapshotAt, + updatedAt = snapshotAt, + etag = $"\"{graphId}:v1\"" + }); + }) + .RequireTenant(); + + app.MapGet("/graphs/{graphId}/tiles", async Task ( + string graphId, + bool? includeOverlays, + HttpContext context, + InMemoryGraphRepository repository, + IOverlayService overlayService, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + var startedAt = DateTimeOffset.UtcNow; + var nodes = repository.GetCompatibilityNodes(tenantId); + var edges = repository.GetCompatibilityEdges(tenantId); + var overlays = includeOverlays == true + ? await overlayService.GetOverlaysAsync(tenantId, nodes.Select(node => node.Id), sampleExplain: true, ct) + : null; + + return Results.Ok(new + { + version = "graph.tile.compat.v1", + tenantId, + tile = new + { + id = $"{graphId}:default", + etag = $"\"{graphId}:tile:v1\"" + }, + nodes = nodes.Select(MapNode).ToArray(), + edges = edges.Select(MapEdge).ToArray(), + overlays = overlays is null ? null : new + { + policy = FlattenOverlays(overlays, "policy", MapPolicyOverlay), + vex = FlattenOverlays(overlays, "vex", MapVexOverlay), + aoc = FlattenOverlays(overlays, "aoc", MapAocOverlay) + }, + telemetry = new + { + generationMs = Math.Max(1, (int)(DateTimeOffset.UtcNow - startedAt).TotalMilliseconds), + cache = "miss", + samples = nodes.Count + }, + etag = $"\"{graphId}:tile:v1\"" + }); + }) + .RequireTenant(); + + app.MapGet("/search", async Task ( + string? q, + string? kinds, + string? graphId, + int? pageSize, + HttpContext context, + InMemoryGraphRepository repository, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + if (string.IsNullOrWhiteSpace(q)) + { + return Results.BadRequest(new ErrorResponse + { + Error = "GRAPH_VALIDATION_FAILED", + Message = "q is required." + }); + } + + var tenantId = auth.TenantId!; + if (!string.IsNullOrWhiteSpace(graphId) && !repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + var request = new GraphSearchRequest + { + Kinds = ParseKinds(kinds), + Query = q.Trim(), + Limit = Math.Clamp(pageSize ?? 50, 1, 200) + }; + + var items = repository.Query(tenantId, request) + .Where(node => IsCompatibilityKind(node.Kind)) + .Take(request.Limit!.Value) + .Select(node => new + { + nodeId = node.Id, + kind = NormalizeNodeKind(node.Kind), + label = ResolveNodeLabel(node), + score = ComputeSearchScore(node, q), + severity = NormalizeSeverity(GetAttributeString(node, "severity")), + reachability = GetAttributeString(node, "reachable") + }) + .ToArray(); + + return Results.Ok(new + { + items, + total = items.Length + }); + }) + .RequireTenant(); + + app.MapGet("/paths", async Task ( + string source, + string target, + int? maxDepth, + string? graphId, + HttpContext context, + InMemoryGraphRepository repository, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!string.IsNullOrWhiteSpace(graphId) && !repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + var result = repository.FindCompatibilityPath(tenantId, source, target, Math.Clamp(maxDepth ?? 4, 1, 6)); + if (result is null) + { + return Results.Ok(new { paths = Array.Empty(), shortestLength = 0, totalPaths = 0 }); + } + + var path = result.Value; + var steps = new List(); + for (var index = 0; index < path.Nodes.Count; index++) + { + steps.Add(new + { + node = MapNode(path.Nodes[index]), + edge = index == 0 ? null : MapEdge(path.Edges[index - 1]), + depth = index + }); + } + + return Results.Ok(new + { + paths = new[] { steps }, + shortestLength = path.Edges.Count, + totalPaths = 1 + }); + }) + .RequireTenant(); + + app.MapGet("/graphs/{graphId}/export", async Task ( + string graphId, + string? format, + HttpContext context, + InMemoryGraphRepository repository, + IGraphExportService exportService, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.Export, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + var job = await exportService.StartExportAsync(tenantId, new GraphExportRequest + { + Format = string.IsNullOrWhiteSpace(format) ? "ndjson" : format.Trim(), + IncludeEdges = true, + Kinds = ["artifact", "component", "vuln"] + }, ct); + + return Results.Ok(new + { + exportId = job.JobId, + format = job.Format, + url = $"/graph/export/{job.JobId}", + sha256 = job.Sha256, + size = job.SizeBytes, + expiresAt = job.CompletedAt.AddMinutes(30) + }); + }) + .RequireTenant(); + + app.MapGet("/assets/{assetId}/snapshot", async Task ( + string assetId, + HttpContext context, + InMemoryGraphRepository repository, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + var asset = repository.GetNode(tenantId, assetId); + if (asset is null) + { + return Results.NotFound(); + } + + var adjacency = repository.GetAdjacency(tenantId, assetId); + var componentIds = adjacency.Outgoing + .Where(edge => edge.Kind.Equals("builds", StringComparison.OrdinalIgnoreCase)) + .Select(edge => edge.Target) + .ToArray(); + + var vulnerabilities = componentIds + .SelectMany(componentId => repository.GetAdjacency(tenantId, componentId).Outgoing) + .Where(edge => edge.Kind.Equals("affects", StringComparison.OrdinalIgnoreCase)) + .Select(edge => edge.Target) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return Results.Ok(new + { + assetId, + name = ResolveNodeLabel(asset), + kind = NormalizeNodeKind(asset.Kind), + components = componentIds, + vulnerabilities, + snapshotAt = repository.GetSnapshotTimestamp() + }); + }) + .RequireTenant(); + + app.MapGet("/nodes/{nodeId}/adjacency", async Task ( + string nodeId, + string? graphId, + HttpContext context, + InMemoryGraphRepository repository, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!string.IsNullOrWhiteSpace(graphId) && !repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + if (repository.GetNode(tenantId, nodeId) is null) + { + return Results.NotFound(); + } + + var adjacency = repository.GetAdjacency(tenantId, nodeId); + return Results.Ok(new + { + nodeId, + incoming = adjacency.Incoming.Select(edge => new + { + nodeId = edge.Source, + edgeType = NormalizeEdgeType(edge.Kind) + }).ToArray(), + outgoing = adjacency.Outgoing.Select(edge => new + { + nodeId = edge.Target, + edgeType = NormalizeEdgeType(edge.Kind) + }).ToArray() + }); + }) + .RequireTenant(); + + app.MapGet("/graphs/{graphId}/saved-views", async Task ( + string graphId, + HttpContext context, + InMemoryGraphRepository repository, + IGraphSavedViewStore store, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + return Results.Ok(await store.ListAsync(tenantId, graphId, ct)); + }) + .RequireTenant(); + + app.MapPost("/graphs/{graphId}/saved-views", async Task ( + string graphId, + CreateGraphSavedViewRequest request, + HttpContext context, + InMemoryGraphRepository repository, + IGraphSavedViewStore store, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new ErrorResponse + { + Error = "GRAPH_VALIDATION_FAILED", + Message = "name is required." + }); + } + + var tenantId = auth.TenantId!; + if (!repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + var created = await store.CreateAsync(tenantId, graphId, request, ct); + return Results.Created($"/graphs/{graphId}/saved-views/{created.ViewId}", created); + }) + .RequireTenant(); + + app.MapDelete("/graphs/{graphId}/saved-views/{viewId}", async Task ( + string graphId, + string viewId, + HttpContext context, + InMemoryGraphRepository repository, + IGraphSavedViewStore store, + CancellationToken ct) => + { + var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct); + if (auth.Failure is not null) + { + return auth.Failure; + } + + var tenantId = auth.TenantId!; + if (!repository.GraphExists(tenantId, graphId)) + { + return Results.NotFound(); + } + + return await store.DeleteAsync(tenantId, graphId, viewId, ct) + ? Results.NoContent() + : Results.NotFound(); + }) + .RequireTenant(); + } + + private static async Task<(string? TenantId, IResult? Failure)> AuthorizeAsync( + HttpContext context, + string policyName, + CancellationToken ct) + { + if (!GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var tenantError)) + { + return (null, Results.BadRequest(new ErrorResponse + { + Error = "GRAPH_VALIDATION_FAILED", + Message = string.Equals(tenantError, "tenant_conflict", StringComparison.Ordinal) + ? "Conflicting tenant headers were supplied." + : "Tenant header is required." + })); + } + + var authResult = await context.AuthenticateAsync(GraphHeaderAuthenticationHandler.SchemeName); + if (!authResult.Succeeded || authResult.Principal?.Identity?.IsAuthenticated != true) + { + return (null, Results.Json(new ErrorResponse + { + Error = "GRAPH_UNAUTHORIZED", + Message = "Authentication is required." + }, statusCode: StatusCodes.Status401Unauthorized)); + } + + var authorizationService = context.RequestServices.GetRequiredService(); + var authorized = await authorizationService.AuthorizeAsync(authResult.Principal, resource: null, policyName); + if (!authorized.Succeeded) + { + return (null, Results.Json(new ErrorResponse + { + Error = "GRAPH_FORBIDDEN", + Message = "The supplied scopes do not permit this operation." + }, statusCode: StatusCodes.Status403Forbidden)); + } + + context.User = authResult.Principal; + return (tenantId, null); + } + + private static object MapNode(NodeTile node) + => new + { + id = node.Id, + kind = NormalizeNodeKind(node.Kind), + label = ResolveNodeLabel(node), + severity = NormalizeSeverity(GetAttributeString(node, "severity")), + reachability = GetAttributeString(node, "reachable"), + attributes = node.Attributes + }; + + private static object MapEdge(EdgeTile edge) + => new + { + id = edge.Id, + source = edge.Source, + target = edge.Target, + type = NormalizeEdgeType(edge.Kind), + attributes = edge.Attributes + }; + + private static object[] FlattenOverlays( + IDictionary> overlays, + string overlayKind, + Func mapper) + where T : class + { + return overlays + .Where(item => item.Value.TryGetValue(overlayKind, out _)) + .Select(item => + { + var payload = item.Value[overlayKind]; + return mapper(item.Key, payload); + }) + .Where(item => item is not null) + .Cast() + .ToArray(); + } + + private static object? MapPolicyOverlay(string nodeId, OverlayPayload payload) + { + var data = payload.Data as dynamic; + var badge = ((string?)data?.decision ?? "warn").ToLowerInvariant(); + return new + { + nodeId, + badge = badge switch + { + "fail" => "fail", + "waived" => "waived", + "pass" => "pass", + _ => "warn" + }, + policyId = (string?)data?.overlayId ?? $"policy://{nodeId}", + verdictAt = (DateTimeOffset?)data?.createdAt + }; + } + + private static object? MapVexOverlay(string nodeId, OverlayPayload payload) + { + var data = payload.Data as dynamic; + return new + { + nodeId, + state = ((string?)data?.status ?? "under_investigation").ToLowerInvariant(), + statementId = (string?)data?.overlayId ?? $"vex://{nodeId}", + lastUpdated = (DateTimeOffset?)data?.issued + }; + } + + private static object? MapAocOverlay(string nodeId, OverlayPayload payload) + { + var data = payload.Data as dynamic; + return new + { + nodeId, + status = ((string?)data?.status ?? "pending").ToLowerInvariant(), + lastVerified = (DateTimeOffset?)data?.lastVerified + }; + } + + private static string[] ParseKinds(string? kinds) + { + if (string.IsNullOrWhiteSpace(kinds)) + { + return ["artifact", "component", "vuln"]; + } + + return kinds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(kind => kind.Equals("asset", StringComparison.OrdinalIgnoreCase) ? "artifact" : kind) + .ToArray(); + } + + private static string NormalizeNodeKind(string kind) + => kind.Equals("artifact", StringComparison.OrdinalIgnoreCase) + ? "asset" + : kind.ToLowerInvariant(); + + private static string NormalizeEdgeType(string kind) + => kind.Equals("builds", StringComparison.OrdinalIgnoreCase) + ? "contains" + : kind.Equals("affects", StringComparison.OrdinalIgnoreCase) + ? "affects" + : "depends_on"; + + private static string? NormalizeSeverity(string? severity) + => severity?.ToLowerInvariant() switch + { + "critical" => "critical", + "high" => "high", + "medium" => "medium", + "low" => "low", + "info" => "info", + "informational" => "info", + _ => null + }; + + private static bool IsCompatibilityKind(string kind) + => kind.Equals("artifact", StringComparison.OrdinalIgnoreCase) + || kind.Equals("component", StringComparison.OrdinalIgnoreCase) + || kind.Equals("vuln", StringComparison.OrdinalIgnoreCase); + + private static string ResolveNodeLabel(NodeTile node) + { + var displayName = GetAttributeString(node, "displayName"); + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + var purl = GetAttributeString(node, "purl"); + if (!string.IsNullOrWhiteSpace(purl)) + { + var namePart = purl.Split('/').LastOrDefault() ?? purl; + return namePart.Split('@').FirstOrDefault() ?? namePart; + } + + var cveId = GetAttributeString(node, "cveId"); + if (!string.IsNullOrWhiteSpace(cveId)) + { + return cveId; + } + + return node.Id; + } + + private static string? GetAttributeString(NodeTile node, string key) + => node.Attributes.TryGetValue(key, out var value) && value is not null + ? value.ToString() + : null; + + private static double ComputeSearchScore(NodeTile node, string query) + { + var candidate = ResolveNodeLabel(node); + if (candidate.Equals(query, StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + if (candidate.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return 0.8; + } + + return node.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ? 0.6 : 0.2; + } +} diff --git a/src/Graph/StellaOps.Graph.Api/Migrations/003_saved_views.sql b/src/Graph/StellaOps.Graph.Api/Migrations/003_saved_views.sql new file mode 100644 index 000000000..1b8c83136 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Migrations/003_saved_views.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS saved_views ( + tenant_id TEXT NOT NULL, + graph_id TEXT NOT NULL, + view_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NULL, + filters_json JSONB NULL, + layout_json JSONB NULL, + overlays_json JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, graph_id, view_id) +); + +CREATE INDEX IF NOT EXISTS idx_saved_views_tenant_graph_created_at + ON saved_views (tenant_id, graph_id, created_at DESC, view_id); diff --git a/src/Graph/StellaOps.Graph.Api/Program.cs b/src/Graph/StellaOps.Graph.Api/Program.cs index 413ac5d7c..555428c07 100644 --- a/src/Graph/StellaOps.Graph.Api/Program.cs +++ b/src/Graph/StellaOps.Graph.Api/Program.cs @@ -1,9 +1,13 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Infrastructure.Postgres.Migrations; +using StellaOps.Infrastructure.Postgres.Options; using StellaOps.Localization; +using StellaOps.Graph.Api.Endpoints; using StellaOps.Graph.Api.Contracts; using StellaOps.Graph.Api.Security; using StellaOps.Graph.Api.Services; @@ -21,11 +25,27 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(_ => new RateLimiterService(limitPerWindow: 120)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Postgres:Graph")) + .PostConfigure(options => + { + options.ConnectionString = ResolveGraphConnectionString(builder.Configuration) ?? string.Empty; + options.SchemaName = ResolveGraphSchemaName(builder.Configuration); + }); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + return string.IsNullOrWhiteSpace(options.ConnectionString) + ? sp.GetRequiredService() + : ActivatorUtilities.CreateInstance(sp); +}); builder.Services .AddAuthentication(options => { @@ -495,6 +515,7 @@ app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string eviden .RequireTenant(); app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); +app.MapCompatibilityEndpoints(); app.TryRefreshStellaRouterEndpoints(routerEnabled); await app.RunAsync().ConfigureAwait(false); @@ -600,4 +621,24 @@ static void LogAudit(HttpContext ctx, string route, int statusCode, long duratio DurationMs: durationMs)); } +static string? ResolveGraphConnectionString(IConfiguration configuration) +{ + var configured = configuration["Postgres:Graph:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured.Trim(); + } + + var fallback = configuration.GetConnectionString("Default"); + return string.IsNullOrWhiteSpace(fallback) ? null : fallback.Trim(); +} + +static string ResolveGraphSchemaName(IConfiguration configuration) +{ + var configured = configuration["Postgres:Graph:SchemaName"]; + return string.IsNullOrWhiteSpace(configured) + ? PostgresGraphSavedViewStore.DefaultSchemaName + : configured.Trim(); +} + public partial class Program { } diff --git a/src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewStore.cs b/src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewStore.cs new file mode 100644 index 000000000..1b0141899 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewStore.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Nodes; + +namespace StellaOps.Graph.Api.Services; + +public sealed record GraphSavedViewRecord( + string ViewId, + string GraphId, + string Name, + string? Description, + JsonObject? Filters, + JsonObject? Layout, + IReadOnlyList Overlays, + DateTimeOffset CreatedAt); + +public sealed record CreateGraphSavedViewRequest +{ + public required string Name { get; init; } + public string? Description { get; init; } + public JsonObject? Filters { get; init; } + public JsonObject? Layout { get; init; } + public IReadOnlyList? Overlays { get; init; } +} + +public interface IGraphSavedViewStore +{ + Task> ListAsync(string tenant, string graphId, CancellationToken cancellationToken); + + Task CreateAsync( + string tenant, + string graphId, + CreateGraphSavedViewRequest request, + CancellationToken cancellationToken); + + Task DeleteAsync(string tenant, string graphId, string viewId, CancellationToken cancellationToken); +} diff --git a/src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewsMigrationHostedService.cs b/src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewsMigrationHostedService.cs new file mode 100644 index 000000000..283d59274 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Services/GraphSavedViewsMigrationHostedService.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Infrastructure.Postgres.Migrations; +using StellaOps.Infrastructure.Postgres.Options; + +namespace StellaOps.Graph.Api.Services; + +public sealed class GraphSavedViewsMigrationHostedService : IHostedService +{ + private readonly IOptions _options; + private readonly ILoggerFactory _loggerFactory; + private readonly IHostApplicationLifetime _lifetime; + private StartupMigrationHost? _inner; + + public GraphSavedViewsMigrationHostedService( + IOptions options, + ILoggerFactory loggerFactory, + IHostApplicationLifetime lifetime) + { + _options = options; + _loggerFactory = loggerFactory; + _lifetime = lifetime; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var options = _options.Value; + if (string.IsNullOrWhiteSpace(options.ConnectionString)) + { + return Task.CompletedTask; + } + + _inner = new GraphSavedViewsStartupMigrationHost( + options.ConnectionString, + string.IsNullOrWhiteSpace(options.SchemaName) + ? PostgresGraphSavedViewStore.DefaultSchemaName + : options.SchemaName.Trim(), + _loggerFactory.CreateLogger("Migration.Graph.Api"), + _lifetime); + return _inner.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + => _inner?.StopAsync(cancellationToken) ?? Task.CompletedTask; + + private sealed class GraphSavedViewsStartupMigrationHost : StartupMigrationHost + { + public GraphSavedViewsStartupMigrationHost( + string connectionString, + string schemaName, + ILogger logger, + IHostApplicationLifetime lifetime) + : base( + connectionString, + schemaName, + "Graph.Api", + typeof(Program).Assembly, + logger, + lifetime) + { + } + } +} diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs index 792b1c46f..34a30376e 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs @@ -4,6 +4,8 @@ namespace StellaOps.Graph.Api.Services; public sealed class InMemoryGraphRepository { + private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"]; + private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero); private readonly List _nodes; private readonly List _edges; private readonly Dictionary Nodes, List Edges)> _snapshots; @@ -12,23 +14,30 @@ public sealed class InMemoryGraphRepository { _nodes = seed?.ToList() ?? new List { - new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm" } }, - new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } }, - new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container" } }, - new() { Id = "gn:acme:sbom:sha256:sbom-a", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-a", ["artifact_digest"] = "sha256:abc", ["format"] = "cyclonedx" } }, - new() { Id = "gn:acme:sbom:sha256:sbom-b", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-b", ["artifact_digest"] = "sha256:abc", ["format"] = "spdx" } }, - new() { Id = "gn:acme:component:gamma", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:nuget/Gamma@3.1.4", ["ecosystem"] = "nuget" } }, - new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo",Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } }, - new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo",Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container" } }, + new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "example", ["version"] = "1.0.0" } }, + new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } }, + new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container", ["displayName"] = "auth-service", ["version"] = "2026.04.04" } }, + new() { Id = "gn:acme:sbom:sha256:sbom-a", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-a", ["artifact_digest"] = "sha256:abc", ["format"] = "cyclonedx" } }, + new() { Id = "gn:acme:sbom:sha256:sbom-b", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-b", ["artifact_digest"] = "sha256:abc", ["format"] = "spdx" } }, + new() { Id = "gn:acme:component:gamma", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:nuget/Gamma@3.1.4", ["ecosystem"] = "nuget", ["displayName"] = "gamma", ["version"] = "3.1.4" } }, + new() { Id = "gn:acme:vuln:CVE-2026-1234", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-1234", ["severity"] = "critical", ["cveId"] = "CVE-2026-1234" } }, + new() { Id = "gn:acme:vuln:CVE-2026-5678", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-5678", ["severity"] = "high", ["cveId"] = "CVE-2026-5678" } }, + new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } }, + new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo", Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container", ["displayName"] = "transform-service", ["version"] = "2026.04.04" } }, + new() { Id = "gn:bravo:vuln:CVE-2026-9001", Kind = "vuln", Tenant = "bravo", Attributes = new() { ["displayName"] = "CVE-2026-9001", ["severity"] = "medium", ["cveId"] = "CVE-2026-9001" } }, }; _edges = edges?.ToList() ?? new List { new() { Id = "ge:acme:artifact->component", Kind = "builds", Tenant = "acme", Source = "gn:acme:artifact:sha256:abc", Target = "gn:acme:component:example", Attributes = new() { ["reason"] = "sbom" } }, new() { Id = "ge:acme:component->component", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:example", Target = "gn:acme:component:widget", Attributes = new() { ["scope"] = "runtime" } }, + new() { Id = "ge:acme:component->component:gamma", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:component:gamma", Attributes = new() { ["scope"] = "runtime" } }, + new() { Id = "ge:acme:component->vuln:widget", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:vuln:CVE-2026-1234", Attributes = new() { ["scope"] = "runtime" } }, + new() { Id = "ge:acme:component->vuln:gamma", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:gamma", Target = "gn:acme:vuln:CVE-2026-5678", Attributes = new() { ["scope"] = "runtime" } }, new() { Id = "ge:acme:sbom->artifact", Kind = "SBOM_VERSION_OF", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:artifact:sha256:abc", Attributes = new() { ["relationship"] = "version_of" } }, new() { Id = "ge:acme:sbom->sbom", Kind = "SBOM_LINEAGE_PARENT", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:sbom:sha256:sbom-a", Attributes = new() { ["relationship"] = "parent" } }, new() { Id = "ge:bravo:artifact->component", Kind = "builds", Tenant = "bravo", Source = "gn:bravo:artifact:sha256:def", Target = "gn:bravo:component:widget", Attributes = new() { ["reason"] = "sbom" } }, + new() { Id = "ge:bravo:component->vuln:widget", Kind = "affects", Tenant = "bravo", Source = "gn:bravo:component:widget", Target = "gn:bravo:vuln:CVE-2026-9001", Attributes = new() { ["scope"] = "runtime" } }, }; // Drop edges whose endpoints aren't present in the current node set to avoid invalid graph seeds in tests. @@ -195,6 +204,101 @@ public sealed class InMemoryGraphRepository return null; } + public IReadOnlyList GetCompatibilityNodes(string tenant) + => _nodes + .Where(node => node.Tenant.Equals(tenant, StringComparison.Ordinal)) + .Where(node => CompatibilityKinds.Contains(node.Kind, StringComparer.OrdinalIgnoreCase)) + .OrderBy(node => node.Id, StringComparer.Ordinal) + .ToList(); + + public IReadOnlyList GetCompatibilityEdges(string tenant) + { + var nodeIds = GetCompatibilityNodes(tenant).Select(node => node.Id).ToHashSet(StringComparer.Ordinal); + return _edges + .Where(edge => edge.Tenant.Equals(tenant, StringComparison.Ordinal)) + .Where(edge => nodeIds.Contains(edge.Source) && nodeIds.Contains(edge.Target)) + .OrderBy(edge => edge.Id, StringComparer.Ordinal) + .ToList(); + } + + public static string BuildGraphId(string tenant) + => $"graph::{tenant}::main"; + + public bool GraphExists(string tenant, string graphId) + => string.Equals(BuildGraphId(tenant), graphId, StringComparison.OrdinalIgnoreCase); + + public NodeTile? GetNode(string tenant, string nodeId) + => _nodes.FirstOrDefault(node => + node.Tenant.Equals(tenant, StringComparison.Ordinal) + && node.Id.Equals(nodeId, StringComparison.Ordinal)); + + public (IReadOnlyList Incoming, IReadOnlyList Outgoing) GetAdjacency(string tenant, string nodeId) + { + var edges = GetCompatibilityEdges(tenant); + var incoming = edges.Where(edge => edge.Target.Equals(nodeId, StringComparison.Ordinal)).ToList(); + var outgoing = edges.Where(edge => edge.Source.Equals(nodeId, StringComparison.Ordinal)).ToList(); + return (incoming, outgoing); + } + + public (IReadOnlyList Nodes, IReadOnlyList Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth) + { + var nodes = GetCompatibilityNodes(tenant).ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodes.ContainsKey(sourceId) || !nodes.ContainsKey(targetId)) + { + return null; + } + + var edges = GetCompatibilityEdges(tenant); + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (var edge in edges) + { + if (!adjacency.TryGetValue(edge.Source, out var list)) + { + list = new List(); + adjacency[edge.Source] = list; + } + + list.Add(edge); + } + + var queue = new Queue<(string NodeId, List PathEdges)>(); + var visited = new HashSet(StringComparer.Ordinal) { sourceId }; + queue.Enqueue((sourceId, new List())); + + while (queue.Count > 0) + { + var (nodeId, pathEdges) = queue.Dequeue(); + if (nodeId.Equals(targetId, StringComparison.Ordinal)) + { + var pathNodes = BuildNodeListFromEdges(nodes, sourceId, pathEdges); + return (pathNodes, pathEdges); + } + + if (pathEdges.Count >= maxDepth || !adjacency.TryGetValue(nodeId, out var outgoing)) + { + continue; + } + + foreach (var edge in outgoing.OrderBy(edge => edge.Id, StringComparer.Ordinal)) + { + if (!visited.Add(edge.Target)) + { + continue; + } + + var nextEdges = new List(pathEdges.Count + 1); + nextEdges.AddRange(pathEdges); + nextEdges.Add(edge); + queue.Enqueue((edge.Target, nextEdges)); + } + } + + return null; + } + + public DateTimeOffset GetSnapshotTimestamp() + => FixedSnapshotAt; + private Dictionary Nodes, List Edges)> SeedSnapshots() { var dict = new Dictionary, List)>(StringComparer.Ordinal); @@ -214,13 +318,14 @@ public sealed class InMemoryGraphRepository } widget.Attributes["purl"] = "pkg:npm/widget@2.1.0"; + widget.Attributes["version"] = "2.1.0"; updatedNodes.Add(new NodeTile { Id = "gn:acme:component:newlib", Kind = "component", Tenant = "acme", - Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm" } + Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "newlib", ["version"] = "1.0.0" } }); var updatedEdges = new List(_edges) @@ -329,6 +434,28 @@ public sealed class InMemoryGraphRepository } return true; } + + private static IReadOnlyList BuildNodeListFromEdges( + IDictionary nodes, + string sourceId, + IReadOnlyList edges) + { + var list = new List(); + if (nodes.TryGetValue(sourceId, out var firstNode)) + { + list.Add(firstNode); + } + + foreach (var edge in edges) + { + if (nodes.TryGetValue(edge.Target, out var node)) + { + list.Add(node); + } + } + + return list; + } } internal static class CursorCodec diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSavedViewStore.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSavedViewStore.cs new file mode 100644 index 000000000..89a561a61 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSavedViewStore.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace StellaOps.Graph.Api.Services; + +public sealed class InMemoryGraphSavedViewStore : IGraphSavedViewStore +{ + private readonly ConcurrentDictionary> _views = new(StringComparer.Ordinal); + private readonly TimeProvider _timeProvider; + + public InMemoryGraphSavedViewStore(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public Task> ListAsync( + string tenant, + string graphId, + CancellationToken cancellationToken) + { + if (!_views.TryGetValue(BuildKey(tenant, graphId), out var items)) + { + return Task.FromResult>([]); + } + + lock (items) + { + return Task.FromResult>(items + .OrderByDescending(item => item.CreatedAt) + .ThenBy(item => item.ViewId, StringComparer.Ordinal) + .ToList()); + } + } + + public Task CreateAsync( + string tenant, + string graphId, + CreateGraphSavedViewRequest request, + CancellationToken cancellationToken) + { + var key = BuildKey(tenant, graphId); + var items = _views.GetOrAdd(key, _ => new List()); + var record = new GraphSavedViewRecord( + ViewId: $"view-{Guid.NewGuid():N}", + GraphId: graphId, + Name: request.Name.Trim(), + Description: string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Filters: request.Filters?.DeepClone() as JsonObject, + Layout: request.Layout?.DeepClone() as JsonObject, + Overlays: (request.Overlays ?? []).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + CreatedAt: _timeProvider.GetUtcNow()); + + lock (items) + { + items.Add(record); + } + + return Task.FromResult(record); + } + + public Task DeleteAsync( + string tenant, + string graphId, + string viewId, + CancellationToken cancellationToken) + { + if (!_views.TryGetValue(BuildKey(tenant, graphId), out var items)) + { + return Task.FromResult(false); + } + + lock (items) + { + var index = items.FindIndex(item => item.ViewId.Equals(viewId, StringComparison.Ordinal)); + if (index < 0) + { + return Task.FromResult(false); + } + + items.RemoveAt(index); + return Task.FromResult(true); + } + } + + private static string BuildKey(string tenant, string graphId) + => $"{tenant}|{graphId}"; +} diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryOverlayService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryOverlayService.cs index 048f27faf..b7200af2c 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryOverlayService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryOverlayService.cs @@ -30,7 +30,8 @@ namespace StellaOps.Graph.Api.Services; cachedBase = new Dictionary(StringComparer.Ordinal) { ["policy"] = BuildPolicyOverlay(tenant, nodeId, includeExplain: false), - ["vex"] = BuildVexOverlay(tenant, nodeId) + ["vex"] = BuildVexOverlay(tenant, nodeId), + ["aoc"] = BuildAocOverlay(tenant, nodeId), }; _cache.Set(cacheKey, cachedBase, new MemoryCacheEntryOptions @@ -62,6 +63,11 @@ namespace StellaOps.Graph.Api.Services; private static OverlayPayload BuildPolicyOverlay(string tenant, string nodeId, bool includeExplain) { var overlayId = ComputeOverlayId(tenant, nodeId, "policy"); + var decision = nodeId.Contains(":vuln:", StringComparison.OrdinalIgnoreCase) + ? "fail" + : nodeId.Contains("widget", StringComparison.OrdinalIgnoreCase) + ? "warn" + : "pass"; return new OverlayPayload( Kind: "policy", Version: "policy.overlay.v1", @@ -69,8 +75,13 @@ namespace StellaOps.Graph.Api.Services; { overlayId, subject = nodeId, - decision = "warn", - rationale = new[] { "policy-default", "missing VEX waiver" }, + decision, + rationale = decision switch + { + "fail" => new[] { "critical reachable dependency", "promotion gate blocked" }, + "warn" => new[] { "runtime dependency review", "waiver missing" }, + _ => new[] { "policy checks satisfied" }, + }, inputs = new { sbomDigest = "sha256:demo-sbom", @@ -92,6 +103,11 @@ namespace StellaOps.Graph.Api.Services; private static OverlayPayload BuildVexOverlay(string tenant, string nodeId) { var overlayId = ComputeOverlayId(tenant, nodeId, "vex"); + var status = nodeId.Contains("1234", StringComparison.OrdinalIgnoreCase) + ? "affected" + : nodeId.Contains("5678", StringComparison.OrdinalIgnoreCase) + ? "under_investigation" + : "not_affected"; return new OverlayPayload( Kind: "vex", Version: "openvex.v1", @@ -99,13 +115,45 @@ namespace StellaOps.Graph.Api.Services; { overlayId, subject = nodeId, - status = "not_affected", - justification = "component_not_present", + status, + justification = status switch + { + "affected" => "vulnerable_code_present", + "under_investigation" => "requires_analysis", + _ => "component_not_present", + }, issued = FixedTimestamp, impacts = Array.Empty() }); } + private static OverlayPayload BuildAocOverlay(string tenant, string nodeId) + { + var overlayId = ComputeOverlayId(tenant, nodeId, "aoc"); + var status = nodeId.Contains(":vuln:", StringComparison.OrdinalIgnoreCase) + ? "fail" + : nodeId.Contains("artifact", StringComparison.OrdinalIgnoreCase) + ? "pass" + : "warn"; + + return new OverlayPayload( + Kind: "aoc", + Version: "aoc.overlay.v1", + Data: new + { + overlayId, + subject = nodeId, + status, + lastVerified = FixedTimestamp, + summary = status switch + { + "fail" => "Assurance controls are incomplete for the selected subject.", + "warn" => "Assurance controls require operator review before promotion.", + _ => "Assurance controls verified for the latest release cycle.", + } + }); + } + private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind) { using var sha = System.Security.Cryptography.SHA256.Create(); diff --git a/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphSavedViewStore.cs b/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphSavedViewStore.cs new file mode 100644 index 000000000..68619bd5c --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphSavedViewStore.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using Npgsql; +using StellaOps.Infrastructure.Postgres.Options; + +namespace StellaOps.Graph.Api.Services; + +public sealed class PostgresGraphSavedViewStore : IGraphSavedViewStore, IAsyncDisposable +{ + public const string DefaultSchemaName = "graph"; + + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + private readonly NpgsqlDataSource _dataSource; + private readonly string _schemaName; + private readonly TimeProvider _timeProvider; + + public PostgresGraphSavedViewStore(IOptions options, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(options); + + var connectionString = options.Value.ConnectionString + ?? throw new InvalidOperationException("Graph saved-view persistence requires a PostgreSQL connection string."); + _dataSource = CreateDataSource(connectionString); + _schemaName = string.IsNullOrWhiteSpace(options.Value.SchemaName) + ? DefaultSchemaName + : options.Value.SchemaName.Trim(); + _timeProvider = timeProvider; + } + + public async Task> ListAsync( + string tenant, + string graphId, + CancellationToken cancellationToken) + { + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + var sql = + $""" + SELECT view_id, name, description, filters_json, layout_json, overlays_json, created_at + FROM {QuoteIdentifier(_schemaName)}.saved_views + WHERE tenant_id = @tenant AND graph_id = @graphId + ORDER BY created_at DESC, view_id + """; + + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("tenant", tenant); + command.Parameters.AddWithValue("graphId", graphId); + + var items = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + items.Add(new GraphSavedViewRecord( + ViewId: reader.GetString(0), + GraphId: graphId, + Name: reader.GetString(1), + Description: reader.IsDBNull(2) ? null : reader.GetString(2), + Filters: ReadJsonObject(reader, 3), + Layout: ReadJsonObject(reader, 4), + Overlays: ReadStringArray(reader, 5), + CreatedAt: reader.GetFieldValue(6))); + } + + return items; + } + + public async Task CreateAsync( + string tenant, + string graphId, + CreateGraphSavedViewRequest request, + CancellationToken cancellationToken) + { + var record = new GraphSavedViewRecord( + ViewId: $"view-{Guid.NewGuid():N}", + GraphId: graphId, + Name: request.Name.Trim(), + Description: string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Filters: request.Filters?.DeepClone() as JsonObject, + Layout: request.Layout?.DeepClone() as JsonObject, + Overlays: NormalizeOverlays(request.Overlays), + CreatedAt: _timeProvider.GetUtcNow()); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + var sql = + $""" + INSERT INTO {QuoteIdentifier(_schemaName)}.saved_views + (tenant_id, graph_id, view_id, name, description, filters_json, layout_json, overlays_json, created_at) + VALUES + (@tenant, @graphId, @viewId, @name, @description, CAST(@filtersJson AS jsonb), CAST(@layoutJson AS jsonb), CAST(@overlaysJson AS jsonb), @createdAt) + """; + + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("tenant", tenant); + command.Parameters.AddWithValue("graphId", graphId); + command.Parameters.AddWithValue("viewId", record.ViewId); + command.Parameters.AddWithValue("name", record.Name); + command.Parameters.AddWithValue("description", (object?)record.Description ?? DBNull.Value); + command.Parameters.AddWithValue("filtersJson", SerializeJsonObject(record.Filters)); + command.Parameters.AddWithValue("layoutJson", SerializeJsonObject(record.Layout)); + command.Parameters.AddWithValue("overlaysJson", JsonSerializer.Serialize(record.Overlays, SerializerOptions)); + command.Parameters.AddWithValue("createdAt", record.CreatedAt); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return record; + } + + public async Task DeleteAsync( + string tenant, + string graphId, + string viewId, + CancellationToken cancellationToken) + { + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + var sql = + $""" + DELETE FROM {QuoteIdentifier(_schemaName)}.saved_views + WHERE tenant_id = @tenant AND graph_id = @graphId AND view_id = @viewId + """; + + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue("tenant", tenant); + command.Parameters.AddWithValue("graphId", graphId); + command.Parameters.AddWithValue("viewId", viewId); + + var deleted = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return deleted > 0; + } + + private static string[] NormalizeOverlays(IReadOnlyList? overlays) + { + return (overlays ?? []) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static JsonObject? ReadJsonObject(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return JsonNode.Parse(reader.GetString(ordinal)) as JsonObject; + } + + private static IReadOnlyList ReadStringArray(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return []; + } + + return JsonSerializer.Deserialize(reader.GetString(ordinal), SerializerOptions) + ?? []; + } + + private static string SerializeJsonObject(JsonObject? value) + => value?.ToJsonString() ?? "null"; + + private static string QuoteIdentifier(string identifier) + => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + + private static NpgsqlDataSource CreateDataSource(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString) + { + ApplicationName = "stellaops-graph-saved-views" + }; + + return new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build(); + } + + public ValueTask DisposeAsync() => _dataSource.DisposeAsync(); +} diff --git a/src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj b/src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj index b91fa3083..169de1b95 100644 --- a/src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj +++ b/src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj @@ -15,8 +15,10 @@ + + diff --git a/src/Graph/StellaOps.Graph.Api/TASKS.md b/src/Graph/StellaOps.Graph.Api/TASKS.md index 1ae0b863f..e497a16b6 100644 --- a/src/Graph/StellaOps.Graph.Api/TASKS.md +++ b/src/Graph/StellaOps.Graph.Api/TASKS.md @@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | Task ID | Status | Notes | | --- | --- | --- | +| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the saved-view PostgreSQL runtime data source and aligned it with the shared transport guardrails. | | AUDIT-0350-M | DONE | Revalidated 2026-01-07; maintainability audit for Graph.Api. | | AUDIT-0350-T | DONE | Revalidated 2026-01-07; test coverage audit for Graph.Api. | | AUDIT-0350-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). | diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphApiPostgresFixture.cs b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphApiPostgresFixture.cs new file mode 100644 index 000000000..93dcd3f7e --- /dev/null +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphApiPostgresFixture.cs @@ -0,0 +1,13 @@ +using System.Reflection; +using StellaOps.Infrastructure.Postgres.Testing; + +namespace StellaOps.Graph.Api.Tests; + +public sealed class GraphApiPostgresFixture : PostgresIntegrationFixture +{ + protected override Assembly? GetMigrationAssembly() + => typeof(Program).Assembly; + + protected override string GetModuleName() + => "GraphApi"; +} diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs new file mode 100644 index 000000000..c73715d42 --- /dev/null +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace StellaOps.Graph.Api.Tests; + +public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture +{ + private readonly GraphApiPostgresFixture _fixture; + + public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture) + { + _fixture = fixture; + } + + [Fact] + [Trait("Category", "Integration")] + [Trait("Intent", "Compatibility")] + public async Task GraphList_ReturnsCompatibilityGraphMetadata() + { + await _fixture.TruncateAllTablesAsync(); + using var factory = CreateFactory(); + using var client = factory.CreateClient(); + using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var document = JsonDocument.Parse(body); + var items = document.RootElement.GetProperty("items"); + Assert.True(items.GetArrayLength() > 0); + Assert.Equal("ready", items[0].GetProperty("status").GetString()); + } + + [Fact] + [Trait("Category", "Integration")] + [Trait("Intent", "Compatibility")] + public async Task TileEndpoint_WithIncludeOverlays_ReturnsPolicyVexAndAocOverlays() + { + await _fixture.TruncateAllTablesAsync(); + using var factory = CreateFactory(); + using var client = factory.CreateClient(); + var graphId = await GetGraphIdAsync(client); + using var request = CreateRequest( + HttpMethod.Get, + $"/graphs/{Uri.EscapeDataString(graphId)}/tiles?includeOverlays=true", + "graph:read"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var document = JsonDocument.Parse(body); + var overlays = document.RootElement.GetProperty("overlays"); + Assert.True(overlays.TryGetProperty("policy", out var policy)); + Assert.True(policy.GetArrayLength() > 0); + Assert.True(overlays.TryGetProperty("vex", out var vex)); + Assert.True(vex.GetArrayLength() > 0); + Assert.True(overlays.TryGetProperty("aoc", out var aoc)); + Assert.True(aoc.GetArrayLength() > 0); + } + + [Fact] + [Trait("Category", "Integration")] + [Trait("Intent", "Compatibility")] + public async Task SavedViews_Crud_PersistsAcrossHostRestart() + { + await _fixture.TruncateAllTablesAsync(); + using var factory = CreateFactory(); + using var client = factory.CreateClient(); + var graphId = await GetGraphIdAsync(client); + var viewName = $"Priority view {Guid.NewGuid():N}"; + + using var createRequest = CreateRequest( + HttpMethod.Post, + $"/graphs/{Uri.EscapeDataString(graphId)}/saved-views", + "graph:query"); + createRequest.Content = JsonContent.Create(new + { + name = viewName, + description = "Compatibility test view", + overlays = new[] { "policy", "vex" } + }); + + var createResponse = await client.SendAsync(createRequest); + var createdBody = await createResponse.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + using var createdDocument = JsonDocument.Parse(createdBody); + var viewId = createdDocument.RootElement.GetProperty("viewId").GetString(); + Assert.False(string.IsNullOrWhiteSpace(viewId)); + + using var listRequest = CreateRequest( + HttpMethod.Get, + $"/graphs/{Uri.EscapeDataString(graphId)}/saved-views", + "graph:read"); + var listResponse = await client.SendAsync(listRequest); + var listBody = await listResponse.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + Assert.Contains(viewName, listBody, StringComparison.Ordinal); + + using var restartFactory = CreateFactory(); + using var restartClient = restartFactory.CreateClient(); + using var restartListRequest = CreateRequest( + HttpMethod.Get, + $"/graphs/{Uri.EscapeDataString(graphId)}/saved-views", + "graph:read"); + var restartListResponse = await restartClient.SendAsync(restartListRequest); + var restartListBody = await restartListResponse.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, restartListResponse.StatusCode); + Assert.Contains(viewName, restartListBody, StringComparison.Ordinal); + + using var deleteRequest = CreateRequest( + HttpMethod.Delete, + $"/graphs/{Uri.EscapeDataString(graphId)}/saved-views/{Uri.EscapeDataString(viewId!)}", + "graph:query"); + var deleteResponse = await restartClient.SendAsync(deleteRequest); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } + + private WebApplicationFactory CreateFactory() + { + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((context, configurationBuilder) => + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString, + ["Postgres:Graph:SchemaName"] = _fixture.SchemaName, + }); + }); + }); + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string scopes) + { + var request = new HttpRequestMessage(method, url); + request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token"); + request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme"); + request.Headers.TryAddWithoutValidation("X-Stella-Scopes", scopes); + return request; + } + + private static async Task GetGraphIdAsync(HttpClient client) + { + using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read"); + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("items")[0].GetProperty("graphId").GetString() + ?? throw new InvalidOperationException("Compatibility graph id was not returned."); + } +} diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/StellaOps.Graph.Api.Tests.csproj b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/StellaOps.Graph.Api.Tests.csproj index 03e685bb3..99892f6b0 100644 --- a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/StellaOps.Graph.Api.Tests.csproj +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/StellaOps.Graph.Api.Tests.csproj @@ -10,6 +10,7 @@ +