wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -1,5 +1,9 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Security;
using StellaOps.Graph.Api.Services;
using StellaOps.Router.AspNet;
@@ -19,6 +23,38 @@ builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IEdgeMetadataService, InMemoryEdgeMetadataService>();
builder.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = GraphHeaderAuthenticationHandler.SchemeName;
options.DefaultChallengeScheme = GraphHeaderAuthenticationHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, GraphHeaderAuthenticationHandler>(
GraphHeaderAuthenticationHandler.SchemeName,
_ => { });
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(GraphPolicies.ReadOrQuery, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context =>
GraphScopeClaimReader.HasAnyScope(context.User, GraphPolicies.ReadOrQueryScopes));
});
options.AddPolicy(GraphPolicies.Query, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context =>
GraphScopeClaimReader.HasAnyScope(context.User, GraphPolicies.QueryScopes));
});
options.AddPolicy(GraphPolicies.Export, policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context =>
GraphScopeClaimReader.HasAnyScope(context.User, GraphPolicies.ExportScopes));
});
});
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
// Stella Router integration
@@ -35,38 +71,22 @@ app.LogStellaOpsLocalHostname("graph");
app.UseStellaOpsCors();
app.UseRouting();
app.TryUseStellaRouter(routerEnabled);
app.UseAuthentication();
app.UseAuthorization();
app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest request, IGraphSearchService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
context.Response.ContentType = "application/x-ndjson";
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/search",
GraphPolicies.ReadOrQuery,
GraphPolicies.ReadOrQueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/search"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/search", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
@@ -78,7 +98,7 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
return Results.Empty;
}
var tenantId = tenant!;
var tenantId = auth.TenantId!;
await foreach (var line in service.SearchAsync(tenantId, request, ct))
{
@@ -95,33 +115,15 @@ app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest reques
{
var sw = System.Diagnostics.Stopwatch.StartNew();
context.Response.ContentType = "application/x-ndjson";
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/query",
GraphPolicies.Query,
GraphPolicies.QueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/query"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/query", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
@@ -133,7 +135,7 @@ app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest reques
return Results.Empty;
}
var tenantId = tenant!;
var tenantId = auth.TenantId!;
await foreach (var line in service.QueryAsync(tenantId, request, ct))
{
@@ -150,33 +152,15 @@ app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request
{
var sw = System.Diagnostics.Stopwatch.StartNew();
context.Response.ContentType = "application/x-ndjson";
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/paths",
GraphPolicies.Query,
GraphPolicies.QueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/paths"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/paths", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
@@ -188,7 +172,7 @@ app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request
return Results.Empty;
}
var tenantId = tenant!;
var tenantId = auth.TenantId!;
await foreach (var line in service.FindPathsAsync(tenantId, request, ct))
{
@@ -205,33 +189,15 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
{
var sw = System.Diagnostics.Stopwatch.StartNew();
context.Response.ContentType = "application/x-ndjson";
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/diff",
GraphPolicies.Query,
GraphPolicies.QueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/diff"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/diff", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
@@ -243,7 +209,7 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
return Results.Empty;
}
var tenantId = tenant!;
var tenantId = auth.TenantId!;
await foreach (var line in service.DiffAsync(tenantId, request, ct))
{
@@ -259,33 +225,15 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest request, IGraphLineageService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/lineage",
GraphPolicies.ReadOrQuery,
GraphPolicies.ReadOrQueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/lineage"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
@@ -297,7 +245,7 @@ app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest re
return Results.Empty;
}
var tenantId = tenant!;
var tenantId = auth.TenantId!;
var response = await service.GetLineageAsync(tenantId, request, ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
@@ -306,34 +254,15 @@ app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest re
app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/export",
GraphPolicies.Export,
GraphPolicies.ExportForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:export"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/export"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/export", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
@@ -345,7 +274,7 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
return Results.Empty;
}
var tenantId = tenant!;
var tenantId = auth.TenantId!;
var job = await service.StartExportAsync(tenantId, request, ct);
var manifest = new
{
@@ -364,41 +293,20 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
app.MapGet("/graph/export/{jobId}", async (string jobId, HttpContext context, IGraphExportService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/export/download",
GraphPolicies.Export,
GraphPolicies.ExportForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
LogAudit(context, "/graph/export/download", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
LogAudit(context, "/graph/export/download", StatusCodes.Status401Unauthorized, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:export"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct);
LogAudit(context, "/graph/export/download", StatusCodes.Status403Forbidden, sw.ElapsedMilliseconds);
return Results.Empty;
}
if (!RateLimit(context, "/graph/export/download"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/export/download", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var job = service.Get(jobId);
if (job is null || !string.Equals(job.Tenant, tenant, StringComparison.Ordinal))
if (job is null || !string.Equals(job.Tenant, auth.TenantId, StringComparison.Ordinal))
{
LogAudit(context, "/graph/export/download", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" });
@@ -417,37 +325,19 @@ app.MapGet("/graph/export/{jobId}", async (string jobId, HttpContext context, IG
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/edges/metadata",
GraphPolicies.ReadOrQuery,
GraphPolicies.ReadOrQueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/edges/metadata"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
var response = await service.GetEdgeMetadataAsync(tenant!, request, ct);
var response = await service.GetEdgeMetadataAsync(auth.TenantId!, request, ct);
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
@@ -455,37 +345,19 @@ app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpCon
app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/edges/metadata",
GraphPolicies.ReadOrQuery,
GraphPolicies.ReadOrQueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/edges/metadata"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
var result = await service.GetSingleEdgeMetadataAsync(tenant!, edgeId, ct);
var result = await service.GetSingleEdgeMetadataAsync(auth.TenantId!, edgeId, ct);
if (result is null)
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
@@ -499,37 +371,19 @@ app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext c
app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/edges/path",
GraphPolicies.Query,
GraphPolicies.QueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/edges/path"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
var edges = await service.GetPathEdgesWithMetadataAsync(tenant!, sourceNodeId, targetNodeId, ct);
var edges = await service.GetPathEdgesWithMetadataAsync(auth.TenantId!, sourceNodeId, targetNodeId, ct);
LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() });
});
@@ -537,33 +391,15 @@ app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sour
app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/edges/by-reason",
GraphPolicies.ReadOrQuery,
GraphPolicies.ReadOrQueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/edges/by-reason"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
@@ -573,7 +409,7 @@ app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit,
return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" });
}
var response = await service.QueryByReasonAsync(tenant!, edgeReason, limit ?? 100, cursor, ct);
var response = await service.QueryByReasonAsync(auth.TenantId!, edgeReason, limit ?? 100, cursor, ct);
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
@@ -581,37 +417,19 @@ app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit,
app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
var auth = await AuthorizeTenantRequestAsync(
context,
"/graph/edges/by-evidence",
GraphPolicies.ReadOrQuery,
GraphPolicies.ReadOrQueryForbiddenMessage,
sw.ElapsedMilliseconds,
ct);
if (!auth.Allowed)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/edges/by-evidence"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
var edges = await service.QueryByEvidenceAsync(tenant!, evidenceType, evidenceRef, ct);
var edges = await service.QueryByEvidenceAsync(auth.TenantId!, evidenceType, evidenceRef, ct);
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(edges);
});
@@ -632,21 +450,74 @@ static async Task WriteError(HttpContext ctx, int status, string code, string me
await ctx.Response.WriteAsync(payload + "\n", ct);
}
static async Task<(bool Allowed, string? TenantId)> AuthorizeTenantRequestAsync(
HttpContext context,
string route,
string policyName,
string forbiddenMessage,
long elapsedMs,
CancellationToken ct)
{
if (!GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var tenantError))
{
await WriteError(
context,
StatusCodes.Status400BadRequest,
"GRAPH_VALIDATION_FAILED",
TranslateTenantResolutionError(tenantError),
ct);
return (false, null);
}
var authResult = await context.AuthenticateAsync(GraphHeaderAuthenticationHandler.SchemeName);
if (!authResult.Succeeded || authResult.Principal?.Identity?.IsAuthenticated != true)
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return (false, null);
}
context.User = authResult.Principal;
if (!RateLimit(context, route))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, route, StatusCodes.Status429TooManyRequests, elapsedMs);
return (false, null);
}
var authorizationService = context.RequestServices.GetRequiredService<IAuthorizationService>();
var authorized = await authorizationService.AuthorizeAsync(authResult.Principal, resource: null, policyName);
if (!authorized.Succeeded)
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", forbiddenMessage, ct);
return (false, null);
}
return (true, tenantId);
}
static string TranslateTenantResolutionError(string? tenantError)
{
return string.Equals(tenantError, "tenant_conflict", StringComparison.Ordinal)
? "Conflicting tenant context"
: $"Missing {StellaOpsHttpHeaderNames.Tenant} header";
}
static bool RateLimit(HttpContext ctx, string route)
{
var limiter = ctx.RequestServices.GetRequiredService<IRateLimiter>();
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
var tenant = GraphRequestContextResolver.ResolveTenantPartitionKey(ctx);
return limiter.Allow(tenant, route);
}
static void LogAudit(HttpContext ctx, string route, int statusCode, long durationMs)
{
var logger = ctx.RequestServices.GetRequiredService<IAuditLogger>();
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
var actor = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "anonymous";
var scopes = ctx.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v?.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>())
.ToArray();
var tenant = GraphRequestContextResolver.TryResolveTenant(ctx, out var resolvedTenant, out _)
? resolvedTenant
: "unknown";
var actor = GraphRequestContextResolver.ResolveActor(ctx, fallback: "anonymous");
var scopes = GraphScopeClaimReader.ReadScopes(ctx.User);
logger.Log(new AuditEvent(
Timestamp: DateTimeOffset.UtcNow,

View File

@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using System.Security.Claims;
using System.Text.Encodings.Web;
namespace StellaOps.Graph.Api.Security;
internal sealed class GraphHeaderAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "GraphHeader";
private const string LegacyTenantHeader = "X-Stella-Tenant";
private const string AlternateTenantHeader = "X-Tenant-Id";
public GraphHeaderAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authorizationValues)
|| string.IsNullOrWhiteSpace(authorizationValues.ToString()))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var claims = new List<Claim>();
var actor = FirstHeaderValue("X-StellaOps-Actor")
?? "graph-api";
claims.Add(new Claim(StellaOpsClaimTypes.Subject, actor));
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
claims.Add(new Claim(ClaimTypes.Name, actor));
var tenant = NormalizeTenant(
FirstHeaderValue(StellaOpsHttpHeaderNames.Tenant)
?? FirstHeaderValue(LegacyTenantHeader)
?? FirstHeaderValue(AlternateTenantHeader));
if (!string.IsNullOrWhiteSpace(tenant))
{
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenant));
claims.Add(new Claim("tid", tenant));
}
GraphScopeClaimReader.AddScopeClaims(claims, Request.Headers["X-StellaOps-Scopes"]);
GraphScopeClaimReader.AddScopeClaims(claims, Request.Headers["X-Stella-Scopes"]);
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
private string? FirstHeaderValue(string headerName)
{
if (!Request.Headers.TryGetValue(headerName, out var values))
{
return null;
}
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}

View File

@@ -0,0 +1,20 @@
using StellaOps.Auth.Abstractions;
namespace StellaOps.Graph.Api.Security;
internal static class GraphPolicies
{
public const string ReadOrQuery = "Graph.ReadOrQuery";
public const string Query = "Graph.Query";
public const string Export = "Graph.Export";
public const string GraphQueryScope = "graph:query";
public static readonly string[] ReadOrQueryScopes = [StellaOpsScopes.GraphRead, GraphQueryScope];
public static readonly string[] QueryScopes = [GraphQueryScope];
public static readonly string[] ExportScopes = [StellaOpsScopes.GraphExport];
public const string ReadOrQueryForbiddenMessage = "Missing graph:read or graph:query scope";
public const string QueryForbiddenMessage = "Missing graph:query scope";
public const string ExportForbiddenMessage = "Missing graph:export scope";
}

View File

@@ -0,0 +1,164 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using System.Security.Claims;
namespace StellaOps.Graph.Api.Security;
internal static class GraphRequestContextResolver
{
private const string LegacyTenantClaim = "tid";
private const string LegacyTenantIdClaim = "tenant_id";
private const string LegacyTenantHeader = "X-Stella-Tenant";
private const string AlternateTenantHeader = "X-Tenant-Id";
private const string ActorHeader = "X-StellaOps-Actor";
public static bool TryResolveTenant(HttpContext context, out string tenantId, out string? error)
{
ArgumentNullException.ThrowIfNull(context);
tenantId = string.Empty;
error = null;
var claimTenant = NormalizeTenant(ResolveTenantClaim(context.User));
var canonicalHeaderTenant = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
var legacyHeaderTenant = ReadTenantHeader(context, LegacyTenantHeader);
var alternateHeaderTenant = ReadTenantHeader(context, AlternateTenantHeader);
if (HasConflictingValues(canonicalHeaderTenant, legacyHeaderTenant, alternateHeaderTenant))
{
error = "tenant_conflict";
return false;
}
var headerTenant = canonicalHeaderTenant ?? legacyHeaderTenant ?? alternateHeaderTenant;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
if (!string.IsNullOrWhiteSpace(headerTenant)
&& !string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
{
error = "tenant_conflict";
return false;
}
tenantId = claimTenant;
return true;
}
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenantId = headerTenant;
return true;
}
error = "tenant_missing";
return false;
}
public static string ResolveTenantPartitionKey(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (TryResolveTenant(context, out var tenantId, out _))
{
return tenantId;
}
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrWhiteSpace(remoteIp))
{
return $"ip:{remoteIp.Trim()}";
}
return "anonymous";
}
public static string ResolveActor(HttpContext context, string fallback = "anonymous")
{
ArgumentNullException.ThrowIfNull(context);
var subject = context.User.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.Trim();
}
var clientId = context.User.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
return clientId.Trim();
}
if (TryResolveHeader(context, ActorHeader, out var actor))
{
return actor;
}
var identityName = context.User.Identity?.Name;
if (!string.IsNullOrWhiteSpace(identityName))
{
return identityName.Trim();
}
return fallback;
}
private static bool HasConflictingValues(params string?[] values)
{
string? baseline = null;
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (baseline is null)
{
baseline = value;
continue;
}
if (!string.Equals(baseline, value, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string? ResolveTenantClaim(ClaimsPrincipal principal)
{
return principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue(LegacyTenantClaim)
?? principal.FindFirstValue(LegacyTenantIdClaim);
}
private static string? ReadTenantHeader(HttpContext context, string headerName)
{
return TryResolveHeader(context, headerName, out var value)
? NormalizeTenant(value)
: null;
}
private static bool TryResolveHeader(HttpContext context, string headerName, out string value)
{
value = string.Empty;
if (!context.Request.Headers.TryGetValue(headerName, out var values))
{
return false;
}
var raw = values.ToString();
if (string.IsNullOrWhiteSpace(raw))
{
return false;
}
value = raw.Trim();
return true;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}

View File

@@ -0,0 +1,99 @@
using StellaOps.Auth.Abstractions;
using System.Security.Claims;
namespace StellaOps.Graph.Api.Security;
internal static class GraphScopeClaimReader
{
private static readonly char[] ScopeSeparators = [' ', ',', ';'];
public static bool HasAnyScope(ClaimsPrincipal principal, params string[] requiredScopes)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(requiredScopes);
if (requiredScopes.Length == 0)
{
return false;
}
var principalScopes = ReadScopes(principal);
return requiredScopes.Any(scope =>
{
var normalized = NormalizeScope(scope);
return normalized is not null && principalScopes.Contains(normalized);
});
}
public static string[] ReadScopes(ClaimsPrincipal principal)
{
ArgumentNullException.ThrowIfNull(principal);
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var claims = principal.Claims
.Where(static claim =>
string.Equals(claim.Type, StellaOpsClaimTypes.Scope, StringComparison.OrdinalIgnoreCase)
|| string.Equals(claim.Type, StellaOpsClaimTypes.ScopeItem, StringComparison.OrdinalIgnoreCase)
|| string.Equals(claim.Type, "scope", StringComparison.OrdinalIgnoreCase)
|| string.Equals(claim.Type, "scp", StringComparison.OrdinalIgnoreCase));
foreach (var claim in claims)
{
AddScopeValues(scopes, claim.Value);
}
return scopes
.OrderBy(static scope => scope, StringComparer.Ordinal)
.ToArray();
}
public static void AddScopeClaims(ICollection<Claim> claims, IEnumerable<string> rawValues)
{
ArgumentNullException.ThrowIfNull(claims);
ArgumentNullException.ThrowIfNull(rawValues);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in rawValues)
{
foreach (var token in SplitScopeValues(value))
{
if (!seen.Add(token))
{
continue;
}
claims.Add(new Claim(StellaOpsClaimTypes.Scope, token));
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, token));
}
}
}
private static void AddScopeValues(ISet<string> scopes, string? rawValue)
{
foreach (var token in SplitScopeValues(rawValue))
{
scopes.Add(token);
}
}
private static IEnumerable<string> SplitScopeValues(string? rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
yield break;
}
var tokens = rawValue.Split(ScopeSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var token in tokens)
{
var normalized = NormalizeScope(token);
if (normalized is not null)
{
yield return normalized;
}
}
}
private static string? NormalizeScope(string? scope)
=> StellaOpsScopes.Normalize(scope);
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| 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). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260222-058-GRAPH-TEN | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: migrated Graph endpoint tenant/scope checks to shared resolver + policy-driven authorization (tenant-aware limiter/audit included). |

View File

@@ -23,6 +23,7 @@ public interface ICveObservationNodeRepository
/// Gets an observation node by ID.
/// </summary>
Task<CveObservationNode?> GetByIdAsync(
string tenantId,
string nodeId,
CancellationToken ct = default);
@@ -83,6 +84,7 @@ public interface ICveObservationNodeRepository
/// Deletes an observation node.
/// </summary>
Task<bool> DeleteAsync(
string tenantId,
string nodeId,
CancellationToken ct = default);

View File

@@ -94,15 +94,18 @@ public sealed class PostgresCveObservationNodeRepository : ICveObservationNodeRe
/// <inheritdoc />
public async Task<CveObservationNode?> GetByIdAsync(
string tenantId,
string nodeId,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM cve_observation_nodes WHERE node_id = @node_id
SELECT * FROM cve_observation_nodes
WHERE tenant_id = @tenant_id AND node_id = @node_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("node_id", nodeId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
@@ -221,15 +224,18 @@ public sealed class PostgresCveObservationNodeRepository : ICveObservationNodeRe
/// <inheritdoc />
public async Task<bool> DeleteAsync(
string tenantId,
string nodeId,
CancellationToken ct = default)
{
const string sql = """
DELETE FROM cve_observation_nodes WHERE node_id = @node_id
DELETE FROM cve_observation_nodes
WHERE tenant_id = @tenant_id AND node_id = @node_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("node_id", nodeId);
var affected = await cmd.ExecuteNonQueryAsync(ct);

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Graph.Indexer.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for GraphIndexerDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.GraphIndexerDbContext))]
public partial class GraphIndexerDbContextModel : RuntimeModel
{
private static GraphIndexerDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new GraphIndexerDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Graph.Indexer.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for GraphIndexerDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class GraphIndexerDbContextModel
{
partial void Initialize()
{
// Stub: when a real compiled model is generated, entity types will be registered here.
// The runtime factory will fall back to reflection-based model building for all schemas
// until this stub is replaced with a full compiled model.
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Context;
public partial class GraphIndexerDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
// No navigation property overlays needed for Graph Indexer;
// all tables are standalone with no foreign key relationships.
}
}

View File

@@ -1,21 +1,150 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Graph.Indexer.Persistence.EfCore.Models;
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for Graph Indexer module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// Maps to the graph PostgreSQL schema: graph_nodes, graph_edges, pending_snapshots,
/// cluster_assignments, centrality_scores, and idempotency_tokens tables.
/// </summary>
public class GraphIndexerDbContext : DbContext
public partial class GraphIndexerDbContext : DbContext
{
public GraphIndexerDbContext(DbContextOptions<GraphIndexerDbContext> options)
private readonly string _schemaName;
public GraphIndexerDbContext(DbContextOptions<GraphIndexerDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "graph"
: schemaName.Trim();
}
public virtual DbSet<GraphNode> GraphNodes { get; set; }
public virtual DbSet<GraphEdge> GraphEdges { get; set; }
public virtual DbSet<PendingSnapshot> PendingSnapshots { get; set; }
public virtual DbSet<ClusterAssignmentEntity> ClusterAssignments { get; set; }
public virtual DbSet<CentralityScoreEntity> CentralityScores { get; set; }
public virtual DbSet<IdempotencyToken> IdempotencyTokens { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("graph");
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
// -- graph_nodes ----------------------------------------------------------
modelBuilder.Entity<GraphNode>(entity =>
{
entity.HasKey(e => e.Id).HasName("graph_nodes_pkey");
entity.ToTable("graph_nodes", schemaName);
entity.HasIndex(e => e.BatchId, "idx_graph_nodes_batch_id");
entity.HasIndex(e => e.WrittenAt, "idx_graph_nodes_written_at");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.BatchId).HasColumnName("batch_id");
entity.Property(e => e.DocumentJson)
.HasColumnType("jsonb")
.HasColumnName("document_json");
entity.Property(e => e.WrittenAt).HasColumnName("written_at");
});
// -- graph_edges ----------------------------------------------------------
modelBuilder.Entity<GraphEdge>(entity =>
{
entity.HasKey(e => e.Id).HasName("graph_edges_pkey");
entity.ToTable("graph_edges", schemaName);
entity.HasIndex(e => e.BatchId, "idx_graph_edges_batch_id");
entity.HasIndex(e => e.SourceId, "idx_graph_edges_source_id");
entity.HasIndex(e => e.TargetId, "idx_graph_edges_target_id");
entity.HasIndex(e => e.WrittenAt, "idx_graph_edges_written_at");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.BatchId).HasColumnName("batch_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.TargetId).HasColumnName("target_id");
entity.Property(e => e.DocumentJson)
.HasColumnType("jsonb")
.HasColumnName("document_json");
entity.Property(e => e.WrittenAt).HasColumnName("written_at");
});
// -- pending_snapshots ----------------------------------------------------
modelBuilder.Entity<PendingSnapshot>(entity =>
{
entity.HasKey(e => new { e.Tenant, e.SnapshotId }).HasName("pending_snapshots_pkey");
entity.ToTable("pending_snapshots", schemaName);
entity.HasIndex(e => e.QueuedAt, "idx_pending_snapshots_queued_at");
entity.Property(e => e.Tenant).HasColumnName("tenant");
entity.Property(e => e.SnapshotId).HasColumnName("snapshot_id");
entity.Property(e => e.GeneratedAt).HasColumnName("generated_at");
entity.Property(e => e.NodesJson)
.HasColumnType("jsonb")
.HasColumnName("nodes_json");
entity.Property(e => e.EdgesJson)
.HasColumnType("jsonb")
.HasColumnName("edges_json");
entity.Property(e => e.QueuedAt)
.HasDefaultValueSql("now()")
.HasColumnName("queued_at");
});
// -- cluster_assignments --------------------------------------------------
modelBuilder.Entity<ClusterAssignmentEntity>(entity =>
{
entity.HasKey(e => new { e.Tenant, e.SnapshotId, e.NodeId }).HasName("cluster_assignments_pkey");
entity.ToTable("cluster_assignments", schemaName);
entity.HasIndex(e => new { e.Tenant, e.ClusterId }, "idx_cluster_assignments_cluster");
entity.HasIndex(e => e.ComputedAt, "idx_cluster_assignments_computed_at");
entity.Property(e => e.Tenant).HasColumnName("tenant");
entity.Property(e => e.SnapshotId).HasColumnName("snapshot_id");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.ClusterId).HasColumnName("cluster_id");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
});
// -- centrality_scores ----------------------------------------------------
modelBuilder.Entity<CentralityScoreEntity>(entity =>
{
entity.HasKey(e => new { e.Tenant, e.SnapshotId, e.NodeId }).HasName("centrality_scores_pkey");
entity.ToTable("centrality_scores", schemaName);
entity.HasIndex(e => new { e.Tenant, e.Degree }, "idx_centrality_scores_degree")
.IsDescending(false, true);
entity.HasIndex(e => new { e.Tenant, e.Betweenness }, "idx_centrality_scores_betweenness")
.IsDescending(false, true);
entity.HasIndex(e => e.ComputedAt, "idx_centrality_scores_computed_at");
entity.Property(e => e.Tenant).HasColumnName("tenant");
entity.Property(e => e.SnapshotId).HasColumnName("snapshot_id");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.Degree).HasColumnName("degree");
entity.Property(e => e.Betweenness).HasColumnName("betweenness");
entity.Property(e => e.Kind).HasColumnName("kind");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
});
// -- idempotency_tokens ---------------------------------------------------
modelBuilder.Entity<IdempotencyToken>(entity =>
{
entity.HasKey(e => e.SequenceToken).HasName("idempotency_tokens_pkey");
entity.ToTable("idempotency_tokens", schemaName);
entity.HasIndex(e => e.SeenAt, "idx_idempotency_tokens_seen_at");
entity.Property(e => e.SequenceToken).HasColumnName("sequence_token");
entity.Property(e => e.SeenAt)
.HasDefaultValueSql("now()")
.HasColumnName("seen_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for <c>dotnet ef</c> CLI tooling (scaffold, optimize).
/// </summary>
public sealed class GraphIndexerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<GraphIndexerDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=graph,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_GRAPH_EF_CONNECTION";
public GraphIndexerDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<GraphIndexerDbContext>()
.UseNpgsql(connectionString)
.Options;
return new GraphIndexerDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for graph.centrality_scores table.
/// </summary>
public partial class CentralityScoreEntity
{
public string Tenant { get; set; } = null!;
public string SnapshotId { get; set; } = null!;
public string NodeId { get; set; } = null!;
public double Degree { get; set; }
public double Betweenness { get; set; }
public string Kind { get; set; } = null!;
public DateTimeOffset ComputedAt { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for graph.cluster_assignments table.
/// </summary>
public partial class ClusterAssignmentEntity
{
public string Tenant { get; set; } = null!;
public string SnapshotId { get; set; } = null!;
public string NodeId { get; set; } = null!;
public string ClusterId { get; set; } = null!;
public string Kind { get; set; } = null!;
public DateTimeOffset ComputedAt { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for graph.graph_edges table.
/// </summary>
public partial class GraphEdge
{
public string Id { get; set; } = null!;
public string BatchId { get; set; } = null!;
public string SourceId { get; set; } = null!;
public string TargetId { get; set; } = null!;
public string DocumentJson { get; set; } = null!;
public DateTimeOffset WrittenAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for graph.graph_nodes table.
/// </summary>
public partial class GraphNode
{
public string Id { get; set; } = null!;
public string BatchId { get; set; } = null!;
public string DocumentJson { get; set; } = null!;
public DateTimeOffset WrittenAt { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for graph.idempotency_tokens table.
/// </summary>
public partial class IdempotencyToken
{
public string SequenceToken { get; set; } = null!;
public DateTimeOffset SeenAt { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Graph.Indexer.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for graph.pending_snapshots table.
/// </summary>
public partial class PendingSnapshot
{
public string Tenant { get; set; } = null!;
public string SnapshotId { get; set; } = null!;
public DateTimeOffset GeneratedAt { get; set; }
public string NodesJson { get; set; } = null!;
public string EdgesJson { get; set; } = null!;
public DateTimeOffset QueuedAt { get; set; }
}

View File

@@ -0,0 +1,101 @@
-- Graph Indexer Schema Migration 002: EF Core Repository Tables
-- Creates schema-qualified tables used by the EF Core-backed repositories.
-- These tables were previously self-provisioned by repository EnsureTableAsync methods;
-- this migration makes them first-class migration-managed tables.
CREATE SCHEMA IF NOT EXISTS graph;
-- ============================================================================
-- Graph Nodes (schema-qualified, used by PostgresGraphDocumentWriter)
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph.graph_nodes (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL,
document_json JSONB NOT NULL,
written_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_batch_id ON graph.graph_nodes (batch_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_written_at ON graph.graph_nodes (written_at);
-- ============================================================================
-- Graph Edges (schema-qualified, used by PostgresGraphDocumentWriter)
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph.graph_edges (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
document_json JSONB NOT NULL,
written_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_graph_edges_batch_id ON graph.graph_edges (batch_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_source_id ON graph.graph_edges (source_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_target_id ON graph.graph_edges (target_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_written_at ON graph.graph_edges (written_at);
-- ============================================================================
-- Pending Snapshots (used by PostgresGraphSnapshotProvider)
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph.pending_snapshots (
tenant TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL,
nodes_json JSONB NOT NULL,
edges_json JSONB NOT NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant, snapshot_id)
);
CREATE INDEX IF NOT EXISTS idx_pending_snapshots_queued_at ON graph.pending_snapshots (queued_at);
-- ============================================================================
-- Cluster Assignments (used by PostgresGraphAnalyticsWriter)
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph.cluster_assignments (
tenant TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
node_id TEXT NOT NULL,
cluster_id TEXT NOT NULL,
kind TEXT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant, snapshot_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_cluster_assignments_cluster ON graph.cluster_assignments (tenant, cluster_id);
CREATE INDEX IF NOT EXISTS idx_cluster_assignments_computed_at ON graph.cluster_assignments (computed_at);
-- ============================================================================
-- Centrality Scores (used by PostgresGraphAnalyticsWriter)
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph.centrality_scores (
tenant TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
node_id TEXT NOT NULL,
degree DOUBLE PRECISION NOT NULL,
betweenness DOUBLE PRECISION NOT NULL,
kind TEXT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant, snapshot_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_centrality_scores_degree ON graph.centrality_scores (tenant, degree DESC);
CREATE INDEX IF NOT EXISTS idx_centrality_scores_betweenness ON graph.centrality_scores (tenant, betweenness DESC);
CREATE INDEX IF NOT EXISTS idx_centrality_scores_computed_at ON graph.centrality_scores (computed_at);
-- ============================================================================
-- Idempotency Tokens (used by PostgresIdempotencyStore)
-- ============================================================================
CREATE TABLE IF NOT EXISTS graph.idempotency_tokens (
sequence_token TEXT PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_idempotency_tokens_seen_at ON graph.idempotency_tokens (seen_at);

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Graph.Indexer.Persistence.EfCore.CompiledModels;
using StellaOps.Graph.Indexer.Persistence.EfCore.Context;
namespace StellaOps.Graph.Indexer.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="GraphIndexerDbContext"/> instances.
/// Uses the static compiled model when schema matches the default and the model is
/// fully initialized; falls back to reflection-based model building otherwise.
/// </summary>
internal static class GraphIndexerDbContextFactory
{
// The compiled model is only usable after `dotnet ef dbcontext optimize` has been run
// against a provisioned database. Until then the stub model contains zero entity types
// and would cause "type is not included in the model" exceptions on every DbSet access.
// We detect a usable model by checking whether it has at least one entity type.
private static readonly bool s_compiledModelUsable = IsCompiledModelUsable();
public static GraphIndexerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? GraphIndexerDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<GraphIndexerDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (s_compiledModelUsable &&
string.Equals(normalizedSchema, GraphIndexerDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
optionsBuilder.UseModel(GraphIndexerDbContextModel.Instance);
}
return new GraphIndexerDbContext(optionsBuilder.Options, normalizedSchema);
}
private static bool IsCompiledModelUsable()
{
try
{
var model = GraphIndexerDbContextModel.Instance;
return model.GetEntityTypes().Any();
}
catch
{
return false;
}
}
}

View File

@@ -1,18 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Graph.Indexer.Analytics;
using StellaOps.Graph.Indexer.Persistence.EfCore.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
using System.Collections.Immutable;
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IGraphAnalyticsWriter"/>.
/// PostgreSQL (EF Core) implementation of <see cref="IGraphAnalyticsWriter"/>.
/// </summary>
public sealed class PostgresGraphAnalyticsWriter : RepositoryBase<GraphIndexerDataSource>, IGraphAnalyticsWriter
{
private bool _tableInitialized;
private const int WriteCommandTimeoutSeconds = 60;
public PostgresGraphAnalyticsWriter(GraphIndexerDataSource dataSource, ILogger<PostgresGraphAnalyticsWriter> logger)
: base(dataSource, logger)
@@ -26,44 +27,35 @@ public sealed class PostgresGraphAnalyticsWriter : RepositoryBase<GraphIndexerDa
{
ArgumentNullException.ThrowIfNull(snapshot);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, WriteCommandTimeoutSeconds, GetSchemaName());
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// Delete existing assignments for this snapshot
const string deleteSql = @"
DELETE FROM graph.cluster_assignments
WHERE tenant = @tenant AND snapshot_id = @snapshot_id";
var tenant = snapshot.Tenant ?? string.Empty;
var snapshotId = snapshot.SnapshotId ?? string.Empty;
await using (var deleteCommand = CreateCommand(deleteSql, connection, transaction))
{
AddParameter(deleteCommand, "@tenant", snapshot.Tenant ?? string.Empty);
AddParameter(deleteCommand, "@snapshot_id", snapshot.SnapshotId ?? string.Empty);
await deleteCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Delete existing assignments for this snapshot
await dbContext.ClusterAssignments
.Where(ca => ca.Tenant == tenant && ca.SnapshotId == snapshotId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
// Insert new assignments
const string insertSql = @"
INSERT INTO graph.cluster_assignments (tenant, snapshot_id, node_id, cluster_id, kind, computed_at)
VALUES (@tenant, @snapshot_id, @node_id, @cluster_id, @kind, @computed_at)";
var computedAt = snapshot.GeneratedAt;
foreach (var assignment in assignments)
var entities = assignments.Select(assignment => new ClusterAssignmentEntity
{
await using var insertCommand = CreateCommand(insertSql, connection, transaction);
AddParameter(insertCommand, "@tenant", snapshot.Tenant ?? string.Empty);
AddParameter(insertCommand, "@snapshot_id", snapshot.SnapshotId ?? string.Empty);
AddParameter(insertCommand, "@node_id", assignment.NodeId ?? string.Empty);
AddParameter(insertCommand, "@cluster_id", assignment.ClusterId ?? string.Empty);
AddParameter(insertCommand, "@kind", assignment.Kind ?? string.Empty);
AddParameter(insertCommand, "@computed_at", computedAt);
Tenant = tenant,
SnapshotId = snapshotId,
NodeId = assignment.NodeId ?? string.Empty,
ClusterId = assignment.ClusterId ?? string.Empty,
Kind = assignment.Kind ?? string.Empty,
ComputedAt = computedAt
});
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
dbContext.ClusterAssignments.AddRange(entities);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
@@ -81,45 +73,36 @@ public sealed class PostgresGraphAnalyticsWriter : RepositoryBase<GraphIndexerDa
{
ArgumentNullException.ThrowIfNull(snapshot);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, WriteCommandTimeoutSeconds, GetSchemaName());
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
// Delete existing scores for this snapshot
const string deleteSql = @"
DELETE FROM graph.centrality_scores
WHERE tenant = @tenant AND snapshot_id = @snapshot_id";
var tenant = snapshot.Tenant ?? string.Empty;
var snapshotId = snapshot.SnapshotId ?? string.Empty;
await using (var deleteCommand = CreateCommand(deleteSql, connection, transaction))
{
AddParameter(deleteCommand, "@tenant", snapshot.Tenant ?? string.Empty);
AddParameter(deleteCommand, "@snapshot_id", snapshot.SnapshotId ?? string.Empty);
await deleteCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Delete existing scores for this snapshot
await dbContext.CentralityScores
.Where(cs => cs.Tenant == tenant && cs.SnapshotId == snapshotId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
// Insert new scores
const string insertSql = @"
INSERT INTO graph.centrality_scores (tenant, snapshot_id, node_id, degree, betweenness, kind, computed_at)
VALUES (@tenant, @snapshot_id, @node_id, @degree, @betweenness, @kind, @computed_at)";
var computedAt = snapshot.GeneratedAt;
foreach (var score in scores)
var entities = scores.Select(score => new CentralityScoreEntity
{
await using var insertCommand = CreateCommand(insertSql, connection, transaction);
AddParameter(insertCommand, "@tenant", snapshot.Tenant ?? string.Empty);
AddParameter(insertCommand, "@snapshot_id", snapshot.SnapshotId ?? string.Empty);
AddParameter(insertCommand, "@node_id", score.NodeId ?? string.Empty);
AddParameter(insertCommand, "@degree", score.Degree);
AddParameter(insertCommand, "@betweenness", score.Betweenness);
AddParameter(insertCommand, "@kind", score.Kind ?? string.Empty);
AddParameter(insertCommand, "@computed_at", computedAt);
Tenant = tenant,
SnapshotId = snapshotId,
NodeId = score.NodeId ?? string.Empty,
Degree = score.Degree,
Betweenness = score.Betweenness,
Kind = score.Kind ?? string.Empty,
ComputedAt = computedAt
});
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
dbContext.CentralityScores.AddRange(entities);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
@@ -130,53 +113,5 @@ public sealed class PostgresGraphAnalyticsWriter : RepositoryBase<GraphIndexerDa
}
}
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
{
return new NpgsqlCommand(sql, connection, transaction);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS graph;
CREATE TABLE IF NOT EXISTS graph.cluster_assignments (
tenant TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
node_id TEXT NOT NULL,
cluster_id TEXT NOT NULL,
kind TEXT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant, snapshot_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_cluster_assignments_cluster ON graph.cluster_assignments (tenant, cluster_id);
CREATE INDEX IF NOT EXISTS idx_cluster_assignments_computed_at ON graph.cluster_assignments (computed_at);
CREATE TABLE IF NOT EXISTS graph.centrality_scores (
tenant TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
node_id TEXT NOT NULL,
degree DOUBLE PRECISION NOT NULL,
betweenness DOUBLE PRECISION NOT NULL,
kind TEXT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant, snapshot_id, node_id)
);
CREATE INDEX IF NOT EXISTS idx_centrality_scores_degree ON graph.centrality_scores (tenant, degree DESC);
CREATE INDEX IF NOT EXISTS idx_centrality_scores_betweenness ON graph.centrality_scores (tenant, betweenness DESC);
CREATE INDEX IF NOT EXISTS idx_centrality_scores_computed_at ON graph.centrality_scores (computed_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => GraphIndexerDataSource.DefaultSchemaName;
}

View File

@@ -1,8 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism;
using StellaOps.Graph.Indexer.Ingestion.Sbom;
using StellaOps.Graph.Indexer.Persistence.EfCore.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -10,7 +11,7 @@ using System.Text.Json.Nodes;
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IGraphDocumentWriter"/>.
/// PostgreSQL (EF Core) implementation of <see cref="IGraphDocumentWriter"/>.
/// </summary>
public sealed class PostgresGraphDocumentWriter : RepositoryBase<GraphIndexerDataSource>, IGraphDocumentWriter
{
@@ -21,7 +22,7 @@ public sealed class PostgresGraphDocumentWriter : RepositoryBase<GraphIndexerDat
};
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider; private bool _tableInitialized;
private readonly IGuidProvider _guidProvider;
public PostgresGraphDocumentWriter(
GraphIndexerDataSource dataSource,
@@ -38,64 +39,56 @@ public sealed class PostgresGraphDocumentWriter : RepositoryBase<GraphIndexerDat
{
ArgumentNullException.ThrowIfNull(batch);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
var batchId = _guidProvider.NewGuid().ToString("N");
var writtenAt = _timeProvider.GetUtcNow();
// Insert nodes
// Upsert nodes via raw SQL for ON CONFLICT DO UPDATE
foreach (var node in batch.Nodes)
{
var nodeId = ExtractId(node);
var nodeJson = node.ToJsonString();
const string nodeSql = @"
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO graph.graph_nodes (id, batch_id, document_json, written_at)
VALUES (@id, @batch_id, @document_json, @written_at)
VALUES ({0}, {1}, {2}::jsonb, {3})
ON CONFLICT (id) DO UPDATE SET
batch_id = EXCLUDED.batch_id,
document_json = EXCLUDED.document_json,
written_at = EXCLUDED.written_at";
await using var nodeCommand = CreateCommand(nodeSql, connection, transaction);
AddParameter(nodeCommand, "@id", nodeId);
AddParameter(nodeCommand, "@batch_id", batchId);
AddJsonbParameter(nodeCommand, "@document_json", nodeJson);
AddParameter(nodeCommand, "@written_at", writtenAt);
await nodeCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
written_at = EXCLUDED.written_at
""",
[nodeId, batchId, nodeJson, writtenAt],
cancellationToken).ConfigureAwait(false);
}
// Insert edges
// Upsert edges via raw SQL for ON CONFLICT DO UPDATE
foreach (var edge in batch.Edges)
{
var edgeId = ExtractEdgeId(edge);
var edgeJson = edge.ToJsonString();
var sourceId = ExtractString(edge, "source") ?? string.Empty;
var targetId = ExtractString(edge, "target") ?? string.Empty;
const string edgeSql = @"
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO graph.graph_edges (id, batch_id, source_id, target_id, document_json, written_at)
VALUES (@id, @batch_id, @source_id, @target_id, @document_json, @written_at)
VALUES ({0}, {1}, {2}, {3}, {4}::jsonb, {5})
ON CONFLICT (id) DO UPDATE SET
batch_id = EXCLUDED.batch_id,
source_id = EXCLUDED.source_id,
target_id = EXCLUDED.target_id,
document_json = EXCLUDED.document_json,
written_at = EXCLUDED.written_at";
await using var edgeCommand = CreateCommand(edgeSql, connection, transaction);
AddParameter(edgeCommand, "@id", edgeId);
AddParameter(edgeCommand, "@batch_id", batchId);
AddParameter(edgeCommand, "@source_id", ExtractString(edge, "source") ?? string.Empty);
AddParameter(edgeCommand, "@target_id", ExtractString(edge, "target") ?? string.Empty);
AddJsonbParameter(edgeCommand, "@document_json", edgeJson);
AddParameter(edgeCommand, "@written_at", writtenAt);
await edgeCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
written_at = EXCLUDED.written_at
""",
[edgeId, batchId, sourceId, targetId, edgeJson, writtenAt],
cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
@@ -135,49 +128,5 @@ public sealed class PostgresGraphDocumentWriter : RepositoryBase<GraphIndexerDat
return null;
}
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
{
return new NpgsqlCommand(sql, connection, transaction);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS graph;
CREATE TABLE IF NOT EXISTS graph.graph_nodes (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL,
document_json JSONB NOT NULL,
written_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_batch_id ON graph.graph_nodes (batch_id);
CREATE INDEX IF NOT EXISTS idx_graph_nodes_written_at ON graph.graph_nodes (written_at);
CREATE TABLE IF NOT EXISTS graph.graph_edges (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
document_json JSONB NOT NULL,
written_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_graph_edges_batch_id ON graph.graph_edges (batch_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_source_id ON graph.graph_edges (source_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_target_id ON graph.graph_edges (target_id);
CREATE INDEX IF NOT EXISTS idx_graph_edges_written_at ON graph.graph_edges (written_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => GraphIndexerDataSource.DefaultSchemaName;
}

View File

@@ -1,7 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Graph.Indexer.Analytics;
using StellaOps.Graph.Indexer.Persistence.EfCore.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
using System.Collections.Immutable;
using System.Text.Json;
@@ -10,7 +11,7 @@ using System.Text.Json.Nodes;
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IGraphSnapshotProvider"/>.
/// PostgreSQL (EF Core) implementation of <see cref="IGraphSnapshotProvider"/>.
/// </summary>
public sealed class PostgresGraphSnapshotProvider : RepositoryBase<GraphIndexerDataSource>, IGraphSnapshotProvider
{
@@ -20,7 +21,6 @@ public sealed class PostgresGraphSnapshotProvider : RepositoryBase<GraphIndexerD
WriteIndented = false
};
private bool _tableInitialized;
private readonly TimeProvider _timeProvider;
public PostgresGraphSnapshotProvider(
@@ -39,83 +39,63 @@ public sealed class PostgresGraphSnapshotProvider : RepositoryBase<GraphIndexerD
{
ArgumentNullException.ThrowIfNull(snapshot);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var nodesJson = JsonSerializer.Serialize(snapshot.Nodes.Select(n => n.ToJsonString()), JsonOptions);
var edgesJson = JsonSerializer.Serialize(snapshot.Edges.Select(e => e.ToJsonString()), JsonOptions);
var tenant = snapshot.Tenant ?? string.Empty;
var snapshotId = snapshot.SnapshotId ?? string.Empty;
var queuedAt = _timeProvider.GetUtcNow();
const string sql = @"
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for upsert ON CONFLICT pattern
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO graph.pending_snapshots (tenant, snapshot_id, generated_at, nodes_json, edges_json, queued_at)
VALUES (@tenant, @snapshot_id, @generated_at, @nodes_json, @edges_json, @queued_at)
VALUES ({0}, {1}, {2}, {3}::jsonb, {4}::jsonb, {5})
ON CONFLICT (tenant, snapshot_id) DO UPDATE SET
generated_at = EXCLUDED.generated_at,
nodes_json = EXCLUDED.nodes_json,
edges_json = EXCLUDED.edges_json,
queued_at = EXCLUDED.queued_at";
var nodesJson = JsonSerializer.Serialize(snapshot.Nodes.Select(n => n.ToJsonString()), JsonOptions);
var edgesJson = JsonSerializer.Serialize(snapshot.Edges.Select(e => e.ToJsonString()), JsonOptions);
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant", snapshot.Tenant ?? string.Empty);
AddParameter(command, "@snapshot_id", snapshot.SnapshotId ?? string.Empty);
AddParameter(command, "@generated_at", snapshot.GeneratedAt);
AddJsonbParameter(command, "@nodes_json", nodesJson);
AddJsonbParameter(command, "@edges_json", edgesJson);
AddParameter(command, "@queued_at", _timeProvider.GetUtcNow());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
queued_at = EXCLUDED.queued_at
""",
[tenant, snapshotId, snapshot.GeneratedAt, nodesJson, edgesJson, queuedAt],
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<GraphAnalyticsSnapshot>> GetPendingSnapshotsAsync(CancellationToken cancellationToken)
{
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT tenant, snapshot_id, generated_at, nodes_json, edges_json
FROM graph.pending_snapshots
ORDER BY queued_at ASC
LIMIT 100";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var results = new List<GraphAnalyticsSnapshot>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapSnapshot(reader));
}
var entities = await dbContext.PendingSnapshots
.AsNoTracking()
.OrderBy(ps => ps.QueuedAt)
.Take(100)
.ToListAsync(cancellationToken).ConfigureAwait(false);
return results.ToImmutableArray();
return entities.Select(MapSnapshot).ToImmutableArray();
}
public async Task MarkProcessedAsync(string tenant, string snapshotId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
DELETE FROM graph.pending_snapshots
WHERE tenant = @tenant AND snapshot_id = @snapshot_id";
var normalizedTenant = tenant ?? string.Empty;
var normalizedSnapshotId = snapshotId.Trim();
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@tenant", tenant ?? string.Empty);
AddParameter(command, "@snapshot_id", snapshotId.Trim());
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await dbContext.PendingSnapshots
.Where(ps => ps.Tenant == normalizedTenant && ps.SnapshotId == normalizedSnapshotId)
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
private static GraphAnalyticsSnapshot MapSnapshot(NpgsqlDataReader reader)
private static GraphAnalyticsSnapshot MapSnapshot(PendingSnapshot entity)
{
var tenant = reader.GetString(0);
var snapshotId = reader.GetString(1);
var generatedAt = reader.GetFieldValue<DateTimeOffset>(2);
var nodesJson = reader.GetString(3);
var edgesJson = reader.GetString(4);
var nodeStrings = JsonSerializer.Deserialize<List<string>>(nodesJson, JsonOptions) ?? new List<string>();
var edgeStrings = JsonSerializer.Deserialize<List<string>>(edgesJson, JsonOptions) ?? new List<string>();
var nodeStrings = JsonSerializer.Deserialize<List<string>>(entity.NodesJson, JsonOptions) ?? new List<string>();
var edgeStrings = JsonSerializer.Deserialize<List<string>>(entity.EdgesJson, JsonOptions) ?? new List<string>();
var nodes = nodeStrings
.Select(s => JsonNode.Parse(s) as JsonObject)
@@ -129,35 +109,8 @@ public sealed class PostgresGraphSnapshotProvider : RepositoryBase<GraphIndexerD
.Cast<JsonObject>()
.ToImmutableArray();
return new GraphAnalyticsSnapshot(tenant, snapshotId, generatedAt, nodes, edges);
return new GraphAnalyticsSnapshot(entity.Tenant, entity.SnapshotId, entity.GeneratedAt, nodes, edges);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS graph;
CREATE TABLE IF NOT EXISTS graph.pending_snapshots (
tenant TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL,
nodes_json JSONB NOT NULL,
edges_json JSONB NOT NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant, snapshot_id)
);
CREATE INDEX IF NOT EXISTS idx_pending_snapshots_queued_at ON graph.pending_snapshots (queued_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => GraphIndexerDataSource.DefaultSchemaName;
}

View File

@@ -1,16 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Graph.Indexer.Incremental;
using StellaOps.Graph.Indexer.Persistence.EfCore.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Graph.Indexer.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of <see cref="IIdempotencyStore"/>.
/// PostgreSQL (EF Core) implementation of <see cref="IIdempotencyStore"/>.
/// </summary>
public sealed class PostgresIdempotencyStore : RepositoryBase<GraphIndexerDataSource>, IIdempotencyStore
{
private bool _tableInitialized;
public PostgresIdempotencyStore(GraphIndexerDataSource dataSource, ILogger<PostgresIdempotencyStore> logger)
: base(dataSource, logger)
{
@@ -20,59 +21,36 @@ public sealed class PostgresIdempotencyStore : RepositoryBase<GraphIndexerDataSo
{
ArgumentException.ThrowIfNullOrWhiteSpace(sequenceToken);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = @"
SELECT EXISTS(SELECT 1 FROM graph.idempotency_tokens WHERE sequence_token = @sequence_token)";
var normalizedToken = sequenceToken.Trim();
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@sequence_token", sequenceToken.Trim());
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is bool seen && seen;
return await dbContext.IdempotencyTokens
.AsNoTracking()
.AnyAsync(t => t.SequenceToken == normalizedToken, cancellationToken).ConfigureAwait(false);
}
public async Task MarkSeenAsync(string sequenceToken, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sequenceToken);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var normalizedToken = sequenceToken.Trim();
var seenAt = DateTimeOffset.UtcNow;
const string sql = @"
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = GraphIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for upsert ON CONFLICT DO NOTHING pattern (idempotent)
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO graph.idempotency_tokens (sequence_token, seen_at)
VALUES (@sequence_token, @seen_at)
ON CONFLICT (sequence_token) DO NOTHING";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "@sequence_token", sequenceToken.Trim());
AddParameter(command, "@seen_at", DateTimeOffset.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
VALUES ({0}, {1})
ON CONFLICT (sequence_token) DO NOTHING
""",
[normalizedToken, seenAt],
cancellationToken).ConfigureAwait(false);
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
if (_tableInitialized)
{
return;
}
const string ddl = @"
CREATE SCHEMA IF NOT EXISTS graph;
CREATE TABLE IF NOT EXISTS graph.idempotency_tokens (
sequence_token TEXT PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_idempotency_tokens_seen_at ON graph.idempotency_tokens (seen_at);";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_tableInitialized = true;
}
private static string GetSchemaName() => GraphIndexerDataSource.DefaultSchemaName;
}

View File

@@ -11,7 +11,12 @@
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\GraphIndexerDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Graph.Api.Security;
using System.Security.Claims;
namespace StellaOps.Graph.Api.Tests;
public sealed class GraphRequestContextResolverTests
{
[Fact]
[Trait("Category", "Unit")]
[Trait("Intent", "Safety")]
public void TryResolveTenant_UsesCanonicalHeader_WhenPresent()
{
var context = new DefaultHttpContext();
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "Tenant-A";
var resolved = GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var error);
Assert.True(resolved);
Assert.Null(error);
Assert.Equal("tenant-a", tenantId);
}
[Fact]
[Trait("Category", "Unit")]
[Trait("Intent", "Safety")]
public void TryResolveTenant_RejectsConflictingHeaders()
{
var context = new DefaultHttpContext();
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-a";
context.Request.Headers["X-Stella-Tenant"] = "tenant-b";
var resolved = GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var error);
Assert.False(resolved);
Assert.Equal(string.Empty, tenantId);
Assert.Equal("tenant_conflict", error);
}
[Fact]
[Trait("Category", "Unit")]
[Trait("Intent", "Safety")]
public void TryResolveTenant_RejectsClaimHeaderMismatch()
{
var context = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(StellaOpsClaimTypes.Tenant, "tenant-a")],
authenticationType: "test"))
};
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-b";
var resolved = GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var error);
Assert.False(resolved);
Assert.Equal(string.Empty, tenantId);
Assert.Equal("tenant_conflict", error);
}
[Fact]
[Trait("Category", "Unit")]
[Trait("Intent", "Safety")]
public void TryResolveTenant_ReturnsMissingError_WhenTenantAbsent()
{
var context = new DefaultHttpContext();
var resolved = GraphRequestContextResolver.TryResolveTenant(context, out var tenantId, out var error);
Assert.False(resolved);
Assert.Equal(string.Empty, tenantId);
Assert.Equal("tenant_missing", error);
}
}

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Graph.Api.Tests;
public sealed class GraphTenantAuthorizationAlignmentTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public GraphTenantAuthorizationAlignmentTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder => builder.UseEnvironment("Development"));
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Safety")]
public async Task Query_WithCanonicalTenantAndScopeHeaders_ReturnsOk()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
{
Content = JsonContent.Create(new
{
kinds = new[] { "component" },
query = "widget",
includeEdges = false,
includeStats = false,
limit = 5
})
};
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "acme");
request.Headers.TryAddWithoutValidation("X-StellaOps-Scopes", "graph:query");
var response = await client.SendAsync(request);
var payload = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("\"type\":\"cursor\"", payload, StringComparison.Ordinal);
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Safety")]
public async Task Query_WithConflictingTenantHeaders_ReturnsBadRequest()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
{
Content = JsonContent.Create(new
{
kinds = new[] { "component" },
limit = 1
})
};
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "acme");
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "bravo");
request.Headers.TryAddWithoutValidation("X-StellaOps-Scopes", "graph:query");
var response = await client.SendAsync(request);
var payload = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("GRAPH_VALIDATION_FAILED", payload, StringComparison.Ordinal);
}
[Fact]
[Trait("Category", "Integration")]
[Trait("Intent", "Safety")]
public async Task Query_WithReadOnlyScope_ReturnsForbidden()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/graph/query")
{
Content = JsonContent.Create(new
{
kinds = new[] { "component" },
limit = 1
})
};
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "acme");
request.Headers.TryAddWithoutValidation("X-StellaOps-Scopes", "graph:read");
var response = await client.SendAsync(request);
var payload = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Contains("GRAPH_FORBIDDEN", payload, StringComparison.Ordinal);
Assert.Contains("graph:query", payload, StringComparison.Ordinal);
}
}

View File

@@ -13,3 +13,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| QA-GRAPH-RECHECK-004 | DONE | SPRINT_20260210_005: export download round-trip/authorization regression tests added and passing. |
| QA-GRAPH-RECHECK-005 | DONE | SPRINT_20260210_005: query/overlay API integration tests added to validate runtime data and explain-trace behavior. |
| QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. |
| SPRINT-20260222-058-GRAPH-TEN-05 | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: added focused Graph tenant/auth alignment tests and executed Graph API test project evidence run (`73 passed`). |