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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user