Files
git.stella-ops.org/src/Graph/StellaOps.Graph.Api/Program.cs

504 lines
22 KiB
C#

using StellaOps.Auth.ServerIntegration;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<InMemoryGraphRepository>();
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IEdgeMetadataService, InMemoryEdgeMetadataService>();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.TryAddStellaOpsLocalBinding("graph");
var app = builder.Build();
app.LogStellaOpsLocalHostname("graph");
app.UseStellaOpsCors();
app.UseRouting();
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))
{
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;
}
var validation = SearchValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/search", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
await foreach (var line in service.SearchAsync(tenantId, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/search", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest request, IGraphQueryService 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))
{
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;
}
var validation = QueryValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/query", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
await foreach (var line in service.QueryAsync(tenantId, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/query", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request, IGraphPathService 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))
{
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;
}
var validation = PathValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/paths", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
await foreach (var line in service.FindPathsAsync(tenantId, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/paths", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request, IGraphDiffService 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))
{
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;
}
var validation = DiffValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/diff", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
await foreach (var line in service.DiffAsync(tenantId, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/diff", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
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))
{
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;
}
var validation = LineageValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
var response = await service.GetLineageAsync(tenantId, request, ct);
LogAudit(context, "/graph/lineage", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
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))
{
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;
}
var validation = ExportValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var tenantId = tenant!;
var job = await service.StartExportAsync(tenantId, request, ct);
var manifest = new
{
jobId = job.JobId,
status = "completed",
format = job.Format,
sha256 = job.Sha256,
size = job.SizeBytes,
downloadUrl = $"/graph/export/{job.JobId}",
completedAt = job.CompletedAt
};
LogAudit(context, "/graph/export", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(manifest);
});
app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphExportService service) =>
{
var job = service.Get(jobId);
if (job is null)
{
return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" });
}
context.Response.Headers.ContentLength = job.Payload.Length;
context.Response.Headers["X-Content-SHA256"] = job.Sha256;
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
});
// ────────────────────────────────────────────────────────────────────────────────
// Edge Metadata API
// ────────────────────────────────────────────────────────────────────────────────
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/metadata"))
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var response = await service.GetEdgeMetadataAsync(tenant, request, ct);
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/metadata"))
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var result = await service.GetSingleEdgeMetadataAsync(tenant, edgeId, ct);
if (result is null)
{
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status404NotFound, sw.ElapsedMilliseconds);
return Results.NotFound(new ErrorResponse { Error = "EDGE_NOT_FOUND", Message = $"Edge '{edgeId}' not found" });
}
LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(result);
});
app.MapGet("/graph/edges/path/{sourceNodeId}/{targetNodeId}", async (string sourceNodeId, string targetNodeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/path"))
{
LogAudit(context, "/graph/edges/path", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var edges = await service.GetPathEdgesWithMetadataAsync(tenant, sourceNodeId, targetNodeId, ct);
LogAudit(context, "/graph/edges/path", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(new { sourceNodeId, targetNodeId, edges = edges.ToList() });
});
app.MapGet("/graph/edges/by-reason/{reason}", async (string reason, int? limit, string? cursor, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/by-reason"))
{
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
if (!Enum.TryParse<EdgeReason>(reason, ignoreCase: true, out var edgeReason))
{
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.BadRequest(new ErrorResponse { Error = "INVALID_REASON", Message = $"Unknown edge reason: {reason}" });
}
var response = await service.QueryByReasonAsync(tenant, edgeReason, limit ?? 100, cursor, ct);
LogAudit(context, "/graph/edges/by-reason", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(response);
});
app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "default";
if (!RateLimit(context, "/graph/edges/by-evidence"))
{
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var edges = await service.QueryByEvidenceAsync(tenant, evidenceType, evidenceRef, ct);
LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(new { evidenceType, evidenceRef, edges = edges.ToList() });
});
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.Run();
static async Task WriteError(HttpContext ctx, int status, string code, string message, CancellationToken ct)
{
ctx.Response.StatusCode = status;
var payload = System.Text.Json.JsonSerializer.Serialize(new ErrorResponse
{
Error = code,
Message = message
});
await ctx.Response.WriteAsync(payload + "\n", ct);
}
static bool RateLimit(HttpContext ctx, string route)
{
var limiter = ctx.RequestServices.GetRequiredService<IRateLimiter>();
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
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();
logger.Log(new AuditEvent(
Timestamp: DateTimeOffset.UtcNow,
Tenant: tenant,
Route: route,
Method: ctx.Request.Method,
Actor: actor,
Scopes: scopes,
StatusCode: statusCode,
DurationMs: durationMs));
}