using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Localization; using StellaOps.Graph.Api.Contracts; using StellaOps.Graph.Api.Security; using StellaOps.Graph.Api.Services; using StellaOps.Router.AspNet; using static StellaOps.Localization.T; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(_ => new InMemoryGraphRepository()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new RateLimiterService(limitPerWindow: 120)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services .AddAuthentication(options => { options.DefaultAuthenticateScheme = GraphHeaderAuthenticationHandler.SchemeName; options.DefaultChallengeScheme = GraphHeaderAuthenticationHandler.SchemeName; }) .AddScheme( 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.AddStellaOpsTenantServices(); builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); builder.Services.AddStellaOpsLocalization(builder.Configuration, options => { options.DefaultLocale = string.IsNullOrWhiteSpace(options.DefaultLocale) ? "en-US" : options.DefaultLocale; if (options.SupportedLocales.Count == 0) { options.SupportedLocales.Add("en-US"); } if (!options.SupportedLocales.Contains("de-DE", StringComparer.OrdinalIgnoreCase)) { options.SupportedLocales.Add("de-DE"); } if (string.IsNullOrWhiteSpace(options.RemoteBundleUrl)) { var platformUrl = builder.Configuration["STELLAOPS_PLATFORM_URL"] ?? builder.Configuration["Platform:BaseUrl"]; if (!string.IsNullOrWhiteSpace(platformUrl)) { options.RemoteBundleUrl = platformUrl; } } options.EnableRemoteBundles = options.EnableRemoteBundles || !string.IsNullOrWhiteSpace(options.RemoteBundleUrl); }); builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly()); builder.Services.AddRemoteTranslationBundles(); // Stella Router integration var routerEnabled = builder.Services.AddRouterMicroservice( builder.Configuration, serviceName: "graph", version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0", routerOptionsSection: "Router"); builder.TryAddStellaOpsLocalBinding("graph"); var app = builder.Build(); app.LogStellaOpsLocalHostname("graph"); app.UseStellaOpsCors(); app.UseStellaOpsLocalization(); app.UseRouting(); app.TryUseStellaRouter(routerEnabled); app.UseAuthentication(); app.UseAuthorization(); app.UseStellaOpsTenantMiddleware(); await app.LoadTranslationsAsync(); 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 auth = await AuthorizeTenantRequestAsync( context, "/graph/search", GraphPolicies.ReadOrQuery, GraphPolicies.ReadOrQueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { 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 = auth.TenantId!; 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; }) .RequireTenant(); 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 auth = await AuthorizeTenantRequestAsync( context, "/graph/query", GraphPolicies.Query, GraphPolicies.QueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { 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 = auth.TenantId!; 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; }) .RequireTenant(); 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 auth = await AuthorizeTenantRequestAsync( context, "/graph/paths", GraphPolicies.Query, GraphPolicies.QueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { 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 = auth.TenantId!; 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; }) .RequireTenant(); 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 auth = await AuthorizeTenantRequestAsync( context, "/graph/diff", GraphPolicies.Query, GraphPolicies.QueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { 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 = auth.TenantId!; 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; }) .RequireTenant(); app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest request, IGraphLineageService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var auth = await AuthorizeTenantRequestAsync( context, "/graph/lineage", GraphPolicies.ReadOrQuery, GraphPolicies.ReadOrQueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { 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 = auth.TenantId!; var response = await service.GetLineageAsync(tenantId, request, ct); LogAudit(context, "/graph/lineage", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(response); }) .RequireTenant(); app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var auth = await AuthorizeTenantRequestAsync( context, "/graph/export", GraphPolicies.Export, GraphPolicies.ExportForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { 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 = auth.TenantId!; 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); }) .RequireTenant(); app.MapGet("/graph/export/{jobId}", async (string jobId, HttpContext context, IGraphExportService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var auth = await AuthorizeTenantRequestAsync( context, "/graph/export/download", GraphPolicies.Export, GraphPolicies.ExportForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { return Results.Empty; } var job = service.Get(jobId); 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 = _t("graph.error.export_not_found") }); } context.Response.Headers.ContentLength = job.Payload.Length; context.Response.Headers["X-Content-SHA256"] = job.Sha256; LogAudit(context, "/graph/export/download", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}"); }) .RequireTenant(); // â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€ // Edge Metadata API // â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€ app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var auth = await AuthorizeTenantRequestAsync( context, "/graph/edges/metadata", GraphPolicies.ReadOrQuery, GraphPolicies.ReadOrQueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { return Results.Empty; } var response = await service.GetEdgeMetadataAsync(auth.TenantId!, request, ct); LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(response); }) .RequireTenant(); app.MapGet("/graph/edges/{edgeId}/metadata", async (string edgeId, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var auth = await AuthorizeTenantRequestAsync( context, "/graph/edges/metadata", GraphPolicies.ReadOrQuery, GraphPolicies.ReadOrQueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { return Results.Empty; } var result = await service.GetSingleEdgeMetadataAsync(auth.TenantId!, 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 = _tn("graph.error.edge_not_found", ("edgeId", edgeId)) }); } LogAudit(context, "/graph/edges/metadata", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(result); }) .RequireTenant(); 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 auth = await AuthorizeTenantRequestAsync( context, "/graph/edges/path", GraphPolicies.Query, GraphPolicies.QueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { return Results.Empty; } 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() }); }) .RequireTenant(); 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 auth = await AuthorizeTenantRequestAsync( context, "/graph/edges/by-reason", GraphPolicies.ReadOrQuery, GraphPolicies.ReadOrQueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { return Results.Empty; } if (!Enum.TryParse(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 = _tn("graph.error.invalid_reason", ("reason", reason)) }); } 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); }) .RequireTenant(); app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string evidenceRef, HttpContext context, IEdgeMetadataService service, CancellationToken ct) => { var sw = System.Diagnostics.Stopwatch.StartNew(); var auth = await AuthorizeTenantRequestAsync( context, "/graph/edges/by-evidence", GraphPolicies.ReadOrQuery, GraphPolicies.ReadOrQueryForbiddenMessage, sw.ElapsedMilliseconds, ct); if (!auth.Allowed) { return Results.Empty; } var edges = await service.QueryByEvidenceAsync(auth.TenantId!, evidenceType, evidenceRef, ct); LogAudit(context, "/graph/edges/by-evidence", StatusCodes.Status200OK, sw.ElapsedMilliseconds); return Results.Ok(edges); }) .RequireTenant(); app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); app.TryRefreshStellaRouterEndpoints(routerEnabled); await app.RunAsync().ConfigureAwait(false); 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 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", _t("graph.error.unauthorized_missing_auth"), ct); return (false, null); } context.User = authResult.Principal; if (!RateLimit(context, route)) { await WriteError( context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", _t("graph.error.rate_limited"), ct); LogAudit(context, route, StatusCodes.Status429TooManyRequests, elapsedMs); return (false, null); } var authorizationService = context.RequestServices.GetRequiredService(); 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) ? _t("graph.error.tenant_conflict") : _tn("graph.error.tenant_missing_header", ("header", StellaOpsHttpHeaderNames.Tenant)); } static bool RateLimit(HttpContext ctx, string route) { var limiter = ctx.RequestServices.GetRequiredService(); 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(); 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, Tenant: tenant, Route: route, Method: ctx.Request.Method, Actor: actor, Scopes: scopes, StatusCode: statusCode, DurationMs: durationMs)); } public partial class Program { }