604 lines
22 KiB
C#
604 lines
22 KiB
C#
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<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.AddSingleton<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
|
|
.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.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.AssemblyInformationalVersionAttribute>(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<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 = _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<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)
|
|
? _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<IRateLimiter>();
|
|
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 = 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 { }
|