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) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-06 08:52:37 +03:00
parent 1cff9ef9cc
commit 285f761c77
14 changed files with 1467 additions and 14 deletions

View File

@@ -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<IResult> (
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<IResult> (
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<IResult> (
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<IResult> (
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<IResult> (
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<object>(), shortestLength = 0, totalPaths = 0 });
}
var path = result.Value;
var steps = new List<object>();
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<IResult> (
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<IResult> (
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<IResult> (
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<IResult> (
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<IResult> (
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<IResult> (
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<IAuthorizationService>();
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<T>(
IDictionary<string, Dictionary<string, OverlayPayload>> overlays,
string overlayKind,
Func<string, OverlayPayload, T?> 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<object>()
.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;
}
}

View File

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

View File

@@ -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<IGraphDiffService, InMemoryGraphDiffService>();
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
builder.Services.AddSingleton<IGraphExportService, InMemoryGraphExportService>();
builder.Services.AddSingleton<InMemoryGraphSavedViewStore>();
builder.Services.AddHostedService<GraphSavedViewsMigrationHostedService>();
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.AddOptions<PostgresOptions>()
.Bind(builder.Configuration.GetSection("Postgres:Graph"))
.PostConfigure(options =>
{
options.ConnectionString = ResolveGraphConnectionString(builder.Configuration) ?? string.Empty;
options.SchemaName = ResolveGraphSchemaName(builder.Configuration);
});
builder.Services.AddSingleton<IGraphSavedViewStore>(sp =>
{
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
return string.IsNullOrWhiteSpace(options.ConnectionString)
? sp.GetRequiredService<InMemoryGraphSavedViewStore>()
: ActivatorUtilities.CreateInstance<PostgresGraphSavedViewStore>(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 { }

View File

@@ -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<string> 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<string>? Overlays { get; init; }
}
public interface IGraphSavedViewStore
{
Task<IReadOnlyList<GraphSavedViewRecord>> ListAsync(string tenant, string graphId, CancellationToken cancellationToken);
Task<GraphSavedViewRecord> CreateAsync(
string tenant,
string graphId,
CreateGraphSavedViewRequest request,
CancellationToken cancellationToken);
Task<bool> DeleteAsync(string tenant, string graphId, string viewId, CancellationToken cancellationToken);
}

View File

@@ -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<PostgresOptions> _options;
private readonly ILoggerFactory _loggerFactory;
private readonly IHostApplicationLifetime _lifetime;
private StartupMigrationHost? _inner;
public GraphSavedViewsMigrationHostedService(
IOptions<PostgresOptions> 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)
{
}
}
}

View File

@@ -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<NodeTile> _nodes;
private readonly List<EdgeTile> _edges;
private readonly Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> _snapshots;
@@ -12,23 +14,30 @@ public sealed class InMemoryGraphRepository
{
_nodes = seed?.ToList() ?? new List<NodeTile>
{
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<EdgeTile>
{
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<NodeTile> 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<EdgeTile> 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<EdgeTile> Incoming, IReadOnlyList<EdgeTile> 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<NodeTile> Nodes, IReadOnlyList<EdgeTile> 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<string, List<EdgeTile>>(StringComparer.Ordinal);
foreach (var edge in edges)
{
if (!adjacency.TryGetValue(edge.Source, out var list))
{
list = new List<EdgeTile>();
adjacency[edge.Source] = list;
}
list.Add(edge);
}
var queue = new Queue<(string NodeId, List<EdgeTile> PathEdges)>();
var visited = new HashSet<string>(StringComparer.Ordinal) { sourceId };
queue.Enqueue((sourceId, new List<EdgeTile>()));
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<EdgeTile>(pathEdges.Count + 1);
nextEdges.AddRange(pathEdges);
nextEdges.Add(edge);
queue.Enqueue((edge.Target, nextEdges));
}
}
return null;
}
public DateTimeOffset GetSnapshotTimestamp()
=> FixedSnapshotAt;
private Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> SeedSnapshots()
{
var dict = new Dictionary<string, (List<NodeTile>, List<EdgeTile>)>(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<EdgeTile>(_edges)
@@ -329,6 +434,28 @@ public sealed class InMemoryGraphRepository
}
return true;
}
private static IReadOnlyList<NodeTile> BuildNodeListFromEdges(
IDictionary<string, NodeTile> nodes,
string sourceId,
IReadOnlyList<EdgeTile> edges)
{
var list = new List<NodeTile>();
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

View File

@@ -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<string, List<GraphSavedViewRecord>> _views = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public InMemoryGraphSavedViewStore(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public Task<IReadOnlyList<GraphSavedViewRecord>> ListAsync(
string tenant,
string graphId,
CancellationToken cancellationToken)
{
if (!_views.TryGetValue(BuildKey(tenant, graphId), out var items))
{
return Task.FromResult<IReadOnlyList<GraphSavedViewRecord>>([]);
}
lock (items)
{
return Task.FromResult<IReadOnlyList<GraphSavedViewRecord>>(items
.OrderByDescending(item => item.CreatedAt)
.ThenBy(item => item.ViewId, StringComparer.Ordinal)
.ToList());
}
}
public Task<GraphSavedViewRecord> CreateAsync(
string tenant,
string graphId,
CreateGraphSavedViewRequest request,
CancellationToken cancellationToken)
{
var key = BuildKey(tenant, graphId);
var items = _views.GetOrAdd(key, _ => new List<GraphSavedViewRecord>());
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<bool> 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}";
}

View File

@@ -30,7 +30,8 @@ namespace StellaOps.Graph.Api.Services;
cachedBase = new Dictionary<string, OverlayPayload>(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<string>()
});
}
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();

View File

@@ -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<PostgresOptions> 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<IReadOnlyList<GraphSavedViewRecord>> 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<GraphSavedViewRecord>();
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<DateTimeOffset>(6)));
}
return items;
}
public async Task<GraphSavedViewRecord> 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<bool> 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<string>? 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<string> ReadStringArray(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return [];
}
return JsonSerializer.Deserialize<string[]>(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();
}

View File

@@ -15,8 +15,10 @@
<ItemGroup>
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">

View File

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

View File

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

View File

@@ -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<GraphApiPostgresFixture>
{
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<Program> CreateFactory()
{
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<string> 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.");
}
}

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />