up
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Overrides;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class OverrideEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOverrides(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/overrides")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Overrides");
|
||||
|
||||
group.MapPost("/", CreateOverride)
|
||||
.WithName("CreateOverride")
|
||||
.WithSummary("Create a new override with audit metadata.")
|
||||
.Produces<OverrideResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/{overrideId}", GetOverride)
|
||||
.WithName("GetOverride")
|
||||
.WithSummary("Get an override by ID.")
|
||||
.Produces<OverrideResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{overrideId}", DeleteOverride)
|
||||
.WithName("DeleteOverride")
|
||||
.WithSummary("Delete an override.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/profile/{profileId}", ListProfileOverrides)
|
||||
.WithName("ListProfileOverrides")
|
||||
.WithSummary("List all overrides for a risk profile.")
|
||||
.Produces<OverrideListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/validate", ValidateOverride)
|
||||
.WithName("ValidateOverride")
|
||||
.WithSummary("Validate an override for conflicts before creating.")
|
||||
.Produces<OverrideValidationResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/{overrideId}:approve", ApproveOverride)
|
||||
.WithName("ApproveOverride")
|
||||
.WithSummary("Approve an override that requires review.")
|
||||
.Produces<OverrideResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{overrideId}:disable", DisableOverride)
|
||||
.WithName("DisableOverride")
|
||||
.WithSummary("Disable an active override.")
|
||||
.Produces<OverrideResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{overrideId}/history", GetOverrideHistory)
|
||||
.WithName("GetOverrideHistory")
|
||||
.WithSummary("Get application history for an override.")
|
||||
.Produces<OverrideHistoryResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CreateOverride(
|
||||
HttpContext context,
|
||||
[FromBody] CreateOverrideRequest request,
|
||||
OverrideService overrideService,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Reason is required for audit purposes.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Verify profile exists
|
||||
var profile = profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{request.ProfileId}' was not found.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Validate for conflicts
|
||||
var validation = overrideService.ValidateConflicts(request);
|
||||
if (validation.HasConflicts)
|
||||
{
|
||||
var conflictDetails = string.Join("; ", validation.Conflicts.Select(c => c.Description));
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Override conflicts detected",
|
||||
Detail = conflictDetails,
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Extensions = { ["conflicts"] = validation.Conflicts }
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var auditedOverride = overrideService.Create(request, actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/risk/overrides/{auditedOverride.OverrideId}",
|
||||
new OverrideResponse(auditedOverride, validation.Warnings));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetOverride(
|
||||
HttpContext context,
|
||||
[FromRoute] string overrideId,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var auditedOverride = overrideService.Get(overrideId);
|
||||
if (auditedOverride == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Override not found",
|
||||
Detail = $"Override '{overrideId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new OverrideResponse(auditedOverride, null));
|
||||
}
|
||||
|
||||
private static IResult DeleteOverride(
|
||||
HttpContext context,
|
||||
[FromRoute] string overrideId,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!overrideService.Delete(overrideId))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Override not found",
|
||||
Detail = $"Override '{overrideId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult ListProfileOverrides(
|
||||
HttpContext context,
|
||||
[FromRoute] string profileId,
|
||||
[FromQuery] bool includeInactive,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var overrides = overrideService.ListByProfile(profileId, includeInactive);
|
||||
|
||||
return Results.Ok(new OverrideListResponse(profileId, overrides));
|
||||
}
|
||||
|
||||
private static IResult ValidateOverride(
|
||||
HttpContext context,
|
||||
[FromBody] CreateOverrideRequest request,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var validation = overrideService.ValidateConflicts(request);
|
||||
|
||||
return Results.Ok(new OverrideValidationResponse(validation));
|
||||
}
|
||||
|
||||
private static IResult ApproveOverride(
|
||||
HttpContext context,
|
||||
[FromRoute] string overrideId,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var auditedOverride = overrideService.Approve(overrideId, actorId ?? "system");
|
||||
if (auditedOverride == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Override not found",
|
||||
Detail = $"Override '{overrideId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new OverrideResponse(auditedOverride, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Approval failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DisableOverride(
|
||||
HttpContext context,
|
||||
[FromRoute] string overrideId,
|
||||
[FromQuery] string? reason,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
var auditedOverride = overrideService.Disable(overrideId, actorId ?? "system", reason);
|
||||
if (auditedOverride == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Override not found",
|
||||
Detail = $"Override '{overrideId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new OverrideResponse(auditedOverride, null));
|
||||
}
|
||||
|
||||
private static IResult GetOverrideHistory(
|
||||
HttpContext context,
|
||||
[FromRoute] string overrideId,
|
||||
[FromQuery] int limit,
|
||||
OverrideService overrideService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var effectiveLimit = limit > 0 ? limit : 100;
|
||||
var history = overrideService.GetApplicationHistory(overrideId, effectiveLimit);
|
||||
|
||||
return Results.Ok(new OverrideHistoryResponse(overrideId, history));
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
internal sealed record OverrideResponse(
|
||||
AuditedOverride Override,
|
||||
IReadOnlyList<string>? Warnings);
|
||||
|
||||
internal sealed record OverrideListResponse(
|
||||
string ProfileId,
|
||||
IReadOnlyList<AuditedOverride> Overrides);
|
||||
|
||||
internal sealed record OverrideValidationResponse(OverrideConflictValidation Validation);
|
||||
|
||||
internal sealed record OverrideHistoryResponse(
|
||||
string OverrideId,
|
||||
IReadOnlyList<OverrideApplicationRecord> History);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoint for policy decisions with source evidence summaries (POLICY-ENGINE-40-003).
|
||||
/// </summary>
|
||||
public static class PolicyDecisionEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyDecisions(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/decisions", GetDecisionsAsync)
|
||||
.WithName("PolicyEngine.Decisions")
|
||||
.WithDescription("Request policy decisions with source evidence summaries, top severity sources, and conflict counts.");
|
||||
|
||||
routes.MapGet("/policy/decisions/{snapshotId}", GetDecisionsBySnapshotAsync)
|
||||
.WithName("PolicyEngine.Decisions.BySnapshot")
|
||||
.WithDescription("Get policy decisions for a specific snapshot.");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDecisionsAsync(
|
||||
[FromBody] PolicyDecisionRequest request,
|
||||
PolicyDecisionService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await service.GetDecisionsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDecisionsBySnapshotAsync(
|
||||
[FromRoute] string snapshotId,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] string? componentPurl,
|
||||
[FromQuery] string? advisoryId,
|
||||
[FromQuery] bool includeEvidence = true,
|
||||
[FromQuery] int maxSources = 5,
|
||||
PolicyDecisionService service = default!,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new PolicyDecisionRequest(
|
||||
SnapshotId: snapshotId,
|
||||
TenantId: tenantId,
|
||||
ComponentPurl: componentPurl,
|
||||
AdvisoryId: advisoryId,
|
||||
IncludeEvidence: includeEvidence,
|
||||
MaxSources: maxSources);
|
||||
|
||||
var response = await service.GetDecisionsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Events;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class ProfileEventEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProfileEvents(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/events")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Profile Events");
|
||||
|
||||
group.MapGet("/", GetRecentEvents)
|
||||
.WithName("GetRecentProfileEvents")
|
||||
.WithSummary("Get recent profile lifecycle events.")
|
||||
.Produces<EventListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/filter", GetFilteredEvents)
|
||||
.WithName("GetFilteredProfileEvents")
|
||||
.WithSummary("Get profile events with optional filtering.")
|
||||
.Produces<EventListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/subscribe", CreateSubscription)
|
||||
.WithName("CreateEventSubscription")
|
||||
.WithSummary("Subscribe to profile lifecycle events.")
|
||||
.Produces<SubscriptionResponse>(StatusCodes.Status201Created);
|
||||
|
||||
group.MapDelete("/subscribe/{subscriptionId}", DeleteSubscription)
|
||||
.WithName("DeleteEventSubscription")
|
||||
.WithSummary("Unsubscribe from profile lifecycle events.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/subscribe/{subscriptionId}/poll", PollSubscription)
|
||||
.WithName("PollEventSubscription")
|
||||
.WithSummary("Poll for events from a subscription.")
|
||||
.Produces<EventListResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult GetRecentEvents(
|
||||
HttpContext context,
|
||||
[FromQuery] int limit,
|
||||
ProfileEventPublisher eventPublisher)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var effectiveLimit = limit > 0 ? limit : 100;
|
||||
var events = eventPublisher.GetRecentEvents(effectiveLimit);
|
||||
|
||||
return Results.Ok(new EventListResponse(events));
|
||||
}
|
||||
|
||||
private static IResult GetFilteredEvents(
|
||||
HttpContext context,
|
||||
[FromQuery] ProfileEventType? eventType,
|
||||
[FromQuery] string? profileId,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromQuery] int limit,
|
||||
ProfileEventPublisher eventPublisher)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var effectiveLimit = limit > 0 ? limit : 100;
|
||||
var events = eventPublisher.GetEventsFiltered(eventType, profileId, since, effectiveLimit);
|
||||
|
||||
return Results.Ok(new EventListResponse(events));
|
||||
}
|
||||
|
||||
private static IResult CreateSubscription(
|
||||
HttpContext context,
|
||||
[FromBody] CreateSubscriptionRequest request,
|
||||
ProfileEventPublisher eventPublisher)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.EventTypes == null || request.EventTypes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one event type is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
var subscription = eventPublisher.Subscribe(
|
||||
request.EventTypes,
|
||||
request.ProfileFilter,
|
||||
request.WebhookUrl,
|
||||
actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/risk/events/subscribe/{subscription.SubscriptionId}",
|
||||
new SubscriptionResponse(subscription));
|
||||
}
|
||||
|
||||
private static IResult DeleteSubscription(
|
||||
HttpContext context,
|
||||
[FromRoute] string subscriptionId,
|
||||
ProfileEventPublisher eventPublisher)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!eventPublisher.Unsubscribe(subscriptionId))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Subscription not found",
|
||||
Detail = $"Subscription '{subscriptionId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult PollSubscription(
|
||||
HttpContext context,
|
||||
[FromRoute] string subscriptionId,
|
||||
[FromQuery] int limit,
|
||||
ProfileEventPublisher eventPublisher)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var effectiveLimit = limit > 0 ? limit : 100;
|
||||
var events = eventPublisher.GetEvents(subscriptionId, effectiveLimit);
|
||||
|
||||
// If no events, the subscription might not exist
|
||||
// We return empty list either way since the subscription might just have no events
|
||||
|
||||
return Results.Ok(new EventListResponse(events));
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
internal sealed record EventListResponse(IReadOnlyList<ProfileEvent> Events);
|
||||
|
||||
internal sealed record CreateSubscriptionRequest(
|
||||
IReadOnlyList<ProfileEventType> EventTypes,
|
||||
string? ProfileFilter,
|
||||
string? WebhookUrl);
|
||||
|
||||
internal sealed record SubscriptionResponse(EventSubscription Subscription);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Export;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class ProfileExportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProfileExport(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/profiles/export")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Profile Export/Import");
|
||||
|
||||
group.MapPost("/", ExportProfiles)
|
||||
.WithName("ExportProfiles")
|
||||
.WithSummary("Export risk profiles as a signed bundle.")
|
||||
.Produces<ExportResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/download", DownloadBundle)
|
||||
.WithName("DownloadProfileBundle")
|
||||
.WithSummary("Export and download risk profiles as a JSON file.")
|
||||
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
endpoints.MapPost("/api/risk/profiles/import", ImportProfiles)
|
||||
.RequireAuthorization()
|
||||
.WithName("ImportProfiles")
|
||||
.WithSummary("Import risk profiles from a signed bundle.")
|
||||
.WithTags("Profile Export/Import")
|
||||
.Produces<ImportResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
endpoints.MapPost("/api/risk/profiles/verify", VerifyBundle)
|
||||
.RequireAuthorization()
|
||||
.WithName("VerifyProfileBundle")
|
||||
.WithSummary("Verify the signature of a profile bundle without importing.")
|
||||
.WithTags("Profile Export/Import")
|
||||
.Produces<VerifyResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult ExportProfiles(
|
||||
HttpContext context,
|
||||
[FromBody] ExportProfilesRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
ProfileExportService exportService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.ProfileIds == null || request.ProfileIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one profile ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var profiles = new List<StellaOps.Policy.RiskProfile.Models.RiskProfileModel>();
|
||||
var notFound = new List<string>();
|
||||
|
||||
foreach (var profileId in request.ProfileIds)
|
||||
{
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile != null)
|
||||
{
|
||||
profiles.Add(profile);
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
if (notFound.Count > 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profiles not found",
|
||||
Detail = $"The following profiles were not found: {string.Join(", ", notFound)}",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
var bundle = exportService.Export(profiles, request, actorId);
|
||||
|
||||
return Results.Ok(new ExportResponse(bundle));
|
||||
}
|
||||
|
||||
private static IResult DownloadBundle(
|
||||
HttpContext context,
|
||||
[FromBody] ExportProfilesRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
ProfileExportService exportService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.ProfileIds == null || request.ProfileIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one profile ID is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var profiles = new List<StellaOps.Policy.RiskProfile.Models.RiskProfileModel>();
|
||||
|
||||
foreach (var profileId in request.ProfileIds)
|
||||
{
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile != null)
|
||||
{
|
||||
profiles.Add(profile);
|
||||
}
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
var bundle = exportService.Export(profiles, request, actorId);
|
||||
var json = exportService.SerializeBundle(bundle);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var fileName = $"risk-profiles-{bundle.BundleId}.json";
|
||||
return Results.File(bytes, "application/json", fileName);
|
||||
}
|
||||
|
||||
private static IResult ImportProfiles(
|
||||
HttpContext context,
|
||||
[FromBody] ImportProfilesRequest request,
|
||||
RiskProfileConfigurationService profileService,
|
||||
ProfileExportService exportService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.Bundle == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Bundle is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
// Create an export service with save capability
|
||||
var importExportService = new ProfileExportService(
|
||||
timeProvider: TimeProvider.System,
|
||||
profileLookup: id => profileService.GetProfile(id),
|
||||
lifecycleLookup: null,
|
||||
profileSave: profile => profileService.RegisterProfile(profile),
|
||||
keyLookup: null);
|
||||
|
||||
var result = importExportService.Import(request, actorId);
|
||||
|
||||
return Results.Ok(new ImportResponse(result));
|
||||
}
|
||||
|
||||
private static IResult VerifyBundle(
|
||||
HttpContext context,
|
||||
[FromBody] RiskProfileBundle bundle,
|
||||
ProfileExportService exportService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Bundle is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var verification = exportService.VerifySignature(bundle);
|
||||
|
||||
return Results.Ok(new VerifyResponse(verification, bundle.Metadata));
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
internal sealed record ExportResponse(RiskProfileBundle Bundle);
|
||||
|
||||
internal sealed record ImportResponse(ImportResult Result);
|
||||
|
||||
internal sealed record VerifyResponse(
|
||||
SignatureVerificationResult Verification,
|
||||
BundleMetadata Metadata);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,433 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class RiskSimulationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/simulation")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Simulation");
|
||||
|
||||
group.MapPost("/", RunSimulation)
|
||||
.WithName("RunRiskSimulation")
|
||||
.WithSummary("Run a risk simulation with score distributions and contribution breakdowns.")
|
||||
.Produces<RiskSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/quick", RunQuickSimulation)
|
||||
.WithName("RunQuickRiskSimulation")
|
||||
.WithSummary("Run a quick risk simulation without detailed breakdowns.")
|
||||
.Produces<QuickSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/compare", CompareProfiles)
|
||||
.WithName("CompareProfileSimulations")
|
||||
.WithSummary("Compare risk scoring between two profile configurations.")
|
||||
.Produces<ProfileComparisonResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/whatif", RunWhatIfSimulation)
|
||||
.WithName("RunWhatIfSimulation")
|
||||
.WithSummary("Run a what-if simulation with hypothetical signal changes.")
|
||||
.Produces<WhatIfSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult RunSimulation(
|
||||
HttpContext context,
|
||||
[FromBody] RiskSimulationRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Findings == null || request.Findings.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one finding is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = simulationService.Simulate(request);
|
||||
return Results.Ok(new RiskSimulationResponse(result));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RunQuickSimulation(
|
||||
HttpContext context,
|
||||
[FromBody] QuickSimulationRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var fullRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: false,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Quick);
|
||||
|
||||
try
|
||||
{
|
||||
var result = simulationService.Simulate(fullRequest);
|
||||
|
||||
var quickResponse = new QuickSimulationResponse(
|
||||
SimulationId: result.SimulationId,
|
||||
ProfileId: result.ProfileId,
|
||||
ProfileVersion: result.ProfileVersion,
|
||||
Timestamp: result.Timestamp,
|
||||
AggregateMetrics: result.AggregateMetrics,
|
||||
Distribution: result.Distribution,
|
||||
ExecutionTimeMs: result.ExecutionTimeMs);
|
||||
|
||||
return Results.Ok(quickResponse);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CompareProfiles(
|
||||
HttpContext context,
|
||||
[FromBody] ProfileComparisonRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null ||
|
||||
string.IsNullOrWhiteSpace(request.BaseProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.CompareProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Both BaseProfileId and CompareProfileId are required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var baseRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.BaseProfileId,
|
||||
ProfileVersion: request.BaseProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var compareRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.CompareProfileId,
|
||||
ProfileVersion: request.CompareProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var baseResult = simulationService.Simulate(baseRequest);
|
||||
var compareResult = simulationService.Simulate(compareRequest);
|
||||
|
||||
var deltas = ComputeDeltas(baseResult, compareResult);
|
||||
|
||||
return Results.Ok(new ProfileComparisonResponse(
|
||||
BaseProfile: new ProfileSimulationSummary(
|
||||
baseResult.ProfileId,
|
||||
baseResult.ProfileVersion,
|
||||
baseResult.AggregateMetrics),
|
||||
CompareProfile: new ProfileSimulationSummary(
|
||||
compareResult.ProfileId,
|
||||
compareResult.ProfileVersion,
|
||||
compareResult.AggregateMetrics),
|
||||
Deltas: deltas));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult RunWhatIfSimulation(
|
||||
HttpContext context,
|
||||
[FromBody] WhatIfSimulationRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Run baseline simulation
|
||||
var baselineRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var baselineResult = simulationService.Simulate(baselineRequest);
|
||||
|
||||
// Apply hypothetical changes to findings and re-simulate
|
||||
var modifiedFindings = ApplyHypotheticalChanges(request.Findings, request.HypotheticalChanges);
|
||||
|
||||
var modifiedRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: modifiedFindings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.WhatIf);
|
||||
|
||||
var modifiedResult = simulationService.Simulate(modifiedRequest);
|
||||
|
||||
return Results.Ok(new WhatIfSimulationResponse(
|
||||
BaselineResult: baselineResult,
|
||||
ModifiedResult: modifiedResult,
|
||||
ImpactSummary: ComputeImpactSummary(baselineResult, modifiedResult)));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ComparisonDeltas ComputeDeltas(
|
||||
RiskSimulationResult baseResult,
|
||||
RiskSimulationResult compareResult)
|
||||
{
|
||||
return new ComparisonDeltas(
|
||||
MeanScoreDelta: compareResult.AggregateMetrics.MeanScore - baseResult.AggregateMetrics.MeanScore,
|
||||
MedianScoreDelta: compareResult.AggregateMetrics.MedianScore - baseResult.AggregateMetrics.MedianScore,
|
||||
CriticalCountDelta: compareResult.AggregateMetrics.CriticalCount - baseResult.AggregateMetrics.CriticalCount,
|
||||
HighCountDelta: compareResult.AggregateMetrics.HighCount - baseResult.AggregateMetrics.HighCount,
|
||||
MediumCountDelta: compareResult.AggregateMetrics.MediumCount - baseResult.AggregateMetrics.MediumCount,
|
||||
LowCountDelta: compareResult.AggregateMetrics.LowCount - baseResult.AggregateMetrics.LowCount);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SimulationFinding> ApplyHypotheticalChanges(
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
IReadOnlyList<HypotheticalChange> changes)
|
||||
{
|
||||
var result = new List<SimulationFinding>();
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var modifiedSignals = new Dictionary<string, object?>(finding.Signals);
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.ApplyToAll || change.FindingIds.Contains(finding.FindingId))
|
||||
{
|
||||
modifiedSignals[change.SignalName] = change.NewValue;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(finding with { Signals = modifiedSignals });
|
||||
}
|
||||
|
||||
return result.AsReadOnly();
|
||||
}
|
||||
|
||||
private static WhatIfImpactSummary ComputeImpactSummary(
|
||||
RiskSimulationResult baseline,
|
||||
RiskSimulationResult modified)
|
||||
{
|
||||
var baseScores = baseline.FindingScores.ToDictionary(f => f.FindingId, f => f.NormalizedScore);
|
||||
var modScores = modified.FindingScores.ToDictionary(f => f.FindingId, f => f.NormalizedScore);
|
||||
|
||||
var improved = 0;
|
||||
var worsened = 0;
|
||||
var unchanged = 0;
|
||||
var totalDelta = 0.0;
|
||||
|
||||
foreach (var (findingId, baseScore) in baseScores)
|
||||
{
|
||||
if (modScores.TryGetValue(findingId, out var modScore))
|
||||
{
|
||||
var delta = modScore - baseScore;
|
||||
totalDelta += delta;
|
||||
|
||||
if (Math.Abs(delta) < 0.1)
|
||||
unchanged++;
|
||||
else if (delta < 0)
|
||||
improved++;
|
||||
else
|
||||
worsened++;
|
||||
}
|
||||
}
|
||||
|
||||
return new WhatIfImpactSummary(
|
||||
FindingsImproved: improved,
|
||||
FindingsWorsened: worsened,
|
||||
FindingsUnchanged: unchanged,
|
||||
AverageScoreDelta: baseline.FindingScores.Count > 0
|
||||
? totalDelta / baseline.FindingScores.Count
|
||||
: 0,
|
||||
SeverityShifts: new SeverityShifts(
|
||||
ToLower: improved,
|
||||
ToHigher: worsened,
|
||||
Unchanged: unchanged));
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
internal sealed record RiskSimulationResponse(RiskSimulationResult Result);
|
||||
|
||||
internal sealed record QuickSimulationRequest(
|
||||
string ProfileId,
|
||||
string? ProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings);
|
||||
|
||||
internal sealed record QuickSimulationResponse(
|
||||
string SimulationId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
AggregateRiskMetrics AggregateMetrics,
|
||||
RiskDistribution? Distribution,
|
||||
double ExecutionTimeMs);
|
||||
|
||||
internal sealed record ProfileComparisonRequest(
|
||||
string BaseProfileId,
|
||||
string? BaseProfileVersion,
|
||||
string CompareProfileId,
|
||||
string? CompareProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings);
|
||||
|
||||
internal sealed record ProfileComparisonResponse(
|
||||
ProfileSimulationSummary BaseProfile,
|
||||
ProfileSimulationSummary CompareProfile,
|
||||
ComparisonDeltas Deltas);
|
||||
|
||||
internal sealed record ProfileSimulationSummary(
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
AggregateRiskMetrics Metrics);
|
||||
|
||||
internal sealed record ComparisonDeltas(
|
||||
double MeanScoreDelta,
|
||||
double MedianScoreDelta,
|
||||
int CriticalCountDelta,
|
||||
int HighCountDelta,
|
||||
int MediumCountDelta,
|
||||
int LowCountDelta);
|
||||
|
||||
internal sealed record WhatIfSimulationRequest(
|
||||
string ProfileId,
|
||||
string? ProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
IReadOnlyList<HypotheticalChange> HypotheticalChanges);
|
||||
|
||||
internal sealed record HypotheticalChange(
|
||||
string SignalName,
|
||||
object? NewValue,
|
||||
bool ApplyToAll = true,
|
||||
IReadOnlyList<string>? FindingIds = null)
|
||||
{
|
||||
public IReadOnlyList<string> FindingIds { get; init; } = FindingIds ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed record WhatIfSimulationResponse(
|
||||
RiskSimulationResult BaselineResult,
|
||||
RiskSimulationResult ModifiedResult,
|
||||
WhatIfImpactSummary ImpactSummary);
|
||||
|
||||
internal sealed record WhatIfImpactSummary(
|
||||
int FindingsImproved,
|
||||
int FindingsWorsened,
|
||||
int FindingsUnchanged,
|
||||
double AverageScoreDelta,
|
||||
SeverityShifts SeverityShifts);
|
||||
|
||||
internal sealed record SeverityShifts(
|
||||
int ToLower,
|
||||
int ToHigher,
|
||||
int Unchanged);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
internal static class ScopeAttachmentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapScopeAttachments(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/risk/scopes")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Profile Scopes");
|
||||
|
||||
group.MapPost("/attachments", CreateAttachment)
|
||||
.WithName("CreateScopeAttachment")
|
||||
.WithSummary("Attach a risk profile to a scope (organization, project, environment, or component).")
|
||||
.Produces<ScopeAttachmentResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/attachments/{attachmentId}", GetAttachment)
|
||||
.WithName("GetScopeAttachment")
|
||||
.WithSummary("Get a scope attachment by ID.")
|
||||
.Produces<ScopeAttachmentResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/attachments/{attachmentId}", DeleteAttachment)
|
||||
.WithName("DeleteScopeAttachment")
|
||||
.WithSummary("Delete a scope attachment.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/attachments/{attachmentId}:expire", ExpireAttachment)
|
||||
.WithName("ExpireScopeAttachment")
|
||||
.WithSummary("Expire a scope attachment immediately.")
|
||||
.Produces<ScopeAttachmentResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/attachments", ListAttachments)
|
||||
.WithName("ListScopeAttachments")
|
||||
.WithSummary("List scope attachments with optional filtering.")
|
||||
.Produces<ScopeAttachmentListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/resolve", ResolveScope)
|
||||
.WithName("ResolveScope")
|
||||
.WithSummary("Resolve the effective risk profile for a given scope selector.")
|
||||
.Produces<ScopeResolutionResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{scopeType}/{scopeId}/attachments", GetScopeAttachments)
|
||||
.WithName("GetScopeAttachments")
|
||||
.WithSummary("Get all attachments for a specific scope.")
|
||||
.Produces<ScopeAttachmentListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CreateAttachment(
|
||||
HttpContext context,
|
||||
[FromBody] CreateScopeAttachmentRequest request,
|
||||
ScopeAttachmentService attachmentService,
|
||||
RiskProfileConfigurationService profileService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Verify profile exists
|
||||
var profile = profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = $"Risk profile '{request.ProfileId}' was not found.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
|
||||
try
|
||||
{
|
||||
var attachment = attachmentService.Create(request, actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/risk/scopes/attachments/{attachment.Id}",
|
||||
new ScopeAttachmentResponse(attachment));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetAttachment(
|
||||
HttpContext context,
|
||||
[FromRoute] string attachmentId,
|
||||
ScopeAttachmentService attachmentService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var attachment = attachmentService.Get(attachmentId);
|
||||
if (attachment == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Attachment not found",
|
||||
Detail = $"Scope attachment '{attachmentId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new ScopeAttachmentResponse(attachment));
|
||||
}
|
||||
|
||||
private static IResult DeleteAttachment(
|
||||
HttpContext context,
|
||||
[FromRoute] string attachmentId,
|
||||
ScopeAttachmentService attachmentService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!attachmentService.Delete(attachmentId))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Attachment not found",
|
||||
Detail = $"Scope attachment '{attachmentId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult ExpireAttachment(
|
||||
HttpContext context,
|
||||
[FromRoute] string attachmentId,
|
||||
ScopeAttachmentService attachmentService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
var attachment = attachmentService.Expire(attachmentId, actorId);
|
||||
|
||||
if (attachment == null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Attachment not found",
|
||||
Detail = $"Scope attachment '{attachmentId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new ScopeAttachmentResponse(attachment));
|
||||
}
|
||||
|
||||
private static IResult ListAttachments(
|
||||
HttpContext context,
|
||||
[FromQuery] ScopeType? scopeType,
|
||||
[FromQuery] string? scopeId,
|
||||
[FromQuery] string? profileId,
|
||||
[FromQuery] bool includeExpired,
|
||||
[FromQuery] int limit,
|
||||
ScopeAttachmentService attachmentService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var query = new ScopeAttachmentQuery(
|
||||
ScopeType: scopeType,
|
||||
ScopeId: scopeId,
|
||||
ProfileId: profileId,
|
||||
IncludeExpired: includeExpired,
|
||||
Limit: limit > 0 ? limit : 100);
|
||||
|
||||
var attachments = attachmentService.Query(query);
|
||||
|
||||
return Results.Ok(new ScopeAttachmentListResponse(attachments));
|
||||
}
|
||||
|
||||
private static IResult ResolveScope(
|
||||
HttpContext context,
|
||||
[FromBody] ScopeSelector selector,
|
||||
ScopeAttachmentService attachmentService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (selector == null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Scope selector is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var result = attachmentService.Resolve(selector);
|
||||
|
||||
return Results.Ok(new ScopeResolutionResponse(result));
|
||||
}
|
||||
|
||||
private static IResult GetScopeAttachments(
|
||||
HttpContext context,
|
||||
[FromRoute] ScopeType scopeType,
|
||||
[FromRoute] string scopeId,
|
||||
ScopeAttachmentService attachmentService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var attachments = attachmentService.GetAttachmentsForScope(scopeType, scopeId);
|
||||
|
||||
return Results.Ok(new ScopeAttachmentListResponse(attachments));
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
internal sealed record ScopeAttachmentResponse(ScopeAttachment Attachment);
|
||||
|
||||
internal sealed record ScopeAttachmentListResponse(IReadOnlyList<ScopeAttachment> Attachments);
|
||||
|
||||
internal sealed record ScopeResolutionResponse(ScopeResolutionResult Result);
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user