- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties. - Implemented JSON serialization attributes for proper data interchange. - Created project files for the new signal contracts library and corresponding test projects. - Added deterministic test fixtures for micro-interaction testing. - Included cryptographic keys for secure operations with cosign.
600 lines
22 KiB
C#
600 lines
22 KiB
C#
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.Lifecycle;
|
|
using StellaOps.Policy.RiskProfile.Models;
|
|
|
|
namespace StellaOps.Policy.Engine.Endpoints;
|
|
|
|
internal static class RiskProfileEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapRiskProfiles(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
var group = endpoints.MapGroup("/api/risk/profiles")
|
|
.RequireAuthorization()
|
|
.WithTags("Risk Profiles");
|
|
|
|
group.MapGet(string.Empty, ListProfiles)
|
|
.WithName("ListRiskProfiles")
|
|
.WithSummary("List all available risk profiles.")
|
|
.Produces<RiskProfileListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/{profileId}", GetProfile)
|
|
.WithName("GetRiskProfile")
|
|
.WithSummary("Get a risk profile by ID.")
|
|
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{profileId}/versions", ListVersions)
|
|
.WithName("ListRiskProfileVersions")
|
|
.WithSummary("List all versions of a risk profile.")
|
|
.Produces<RiskProfileVersionListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapGet("/{profileId}/versions/{version}", GetVersion)
|
|
.WithName("GetRiskProfileVersion")
|
|
.WithSummary("Get a specific version of a risk profile.")
|
|
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost(string.Empty, CreateProfile)
|
|
.WithName("CreateRiskProfile")
|
|
.WithSummary("Create a new risk profile version in draft status.")
|
|
.Produces<RiskProfileResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{profileId}/versions/{version}:activate", ActivateProfile)
|
|
.WithName("ActivateRiskProfile")
|
|
.WithSummary("Activate a draft risk profile, making it available for use.")
|
|
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{profileId}/versions/{version}:deprecate", DeprecateProfile)
|
|
.WithName("DeprecateRiskProfile")
|
|
.WithSummary("Deprecate an active risk profile.")
|
|
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{profileId}/versions/{version}:archive", ArchiveProfile)
|
|
.WithName("ArchiveRiskProfile")
|
|
.WithSummary("Archive a risk profile, removing it from active use.")
|
|
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{profileId}/events", GetProfileEvents)
|
|
.WithName("GetRiskProfileEvents")
|
|
.WithSummary("Get lifecycle events for a risk profile.")
|
|
.Produces<RiskProfileEventListResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapPost("/compare", CompareProfiles)
|
|
.WithName("CompareRiskProfiles")
|
|
.WithSummary("Compare two risk profile versions and list differences.")
|
|
.Produces<RiskProfileComparisonResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapGet("/{profileId}/hash", GetProfileHash)
|
|
.WithName("GetRiskProfileHash")
|
|
.WithSummary("Get the deterministic hash of a risk profile.")
|
|
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{profileId}/metadata", GetProfileMetadata)
|
|
.WithName("GetRiskProfileMetadata")
|
|
.WithSummary("Export risk profile metadata for notification enrichment (POLICY-RISK-40-002).")
|
|
.Produces<RiskProfileMetadataExportResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static IResult ListProfiles(
|
|
HttpContext context,
|
|
RiskProfileConfigurationService profileService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var ids = profileService.GetProfileIds();
|
|
var profiles = ids
|
|
.Select(id => profileService.GetProfile(id))
|
|
.Where(p => p != null)
|
|
.Select(p => new RiskProfileSummary(p!.Id, p.Version, p.Description))
|
|
.ToList();
|
|
|
|
return Results.Ok(new RiskProfileListResponse(profiles));
|
|
}
|
|
|
|
private static IResult GetProfile(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
RiskProfileConfigurationService profileService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var profile = profileService.GetProfile(profileId);
|
|
if (profile == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"Risk profile '{profileId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
var hash = profileService.ComputeHash(profile);
|
|
return Results.Ok(new RiskProfileResponse(profile, hash));
|
|
}
|
|
|
|
private static IResult ListVersions(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var versions = lifecycleService.GetAllVersions(profileId);
|
|
return Results.Ok(new RiskProfileVersionListResponse(profileId, versions));
|
|
}
|
|
|
|
private static IResult GetVersion(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromRoute] string version,
|
|
RiskProfileConfigurationService profileService,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var versionInfo = lifecycleService.GetVersionInfo(profileId, version);
|
|
if (versionInfo == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Version not found",
|
|
Detail = $"Risk profile '{profileId}' version '{version}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
var profile = profileService.GetProfile(profileId);
|
|
if (profile == null || profile.Version != version)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"Risk profile '{profileId}' version '{version}' content not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
var hash = profileService.ComputeHash(profile);
|
|
return Results.Ok(new RiskProfileResponse(profile, hash, versionInfo));
|
|
}
|
|
|
|
private static IResult CreateProfile(
|
|
HttpContext context,
|
|
[FromBody] CreateRiskProfileRequest request,
|
|
RiskProfileConfigurationService profileService,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request?.Profile == null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Profile definition is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
try
|
|
{
|
|
var profile = request.Profile;
|
|
profileService.RegisterProfile(profile);
|
|
|
|
var versionInfo = lifecycleService.CreateVersion(profile, actorId);
|
|
var hash = profileService.ComputeHash(profile);
|
|
|
|
return Results.Created(
|
|
$"/api/risk/profiles/{profile.Id}/versions/{profile.Version}",
|
|
new RiskProfileResponse(profile, hash, versionInfo));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Profile creation failed",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult ActivateProfile(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromRoute] string version,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
try
|
|
{
|
|
var versionInfo = lifecycleService.Activate(profileId, version, actorId);
|
|
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (ex.Message.Contains("not found"))
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Activation failed",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult DeprecateProfile(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromRoute] string version,
|
|
[FromBody] DeprecateRiskProfileRequest? request,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
try
|
|
{
|
|
var versionInfo = lifecycleService.Deprecate(
|
|
profileId,
|
|
version,
|
|
request?.SuccessorVersion,
|
|
request?.Reason,
|
|
actorId);
|
|
|
|
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (ex.Message.Contains("not found"))
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Deprecation failed",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult ArchiveProfile(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromRoute] string version,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
|
|
try
|
|
{
|
|
var versionInfo = lifecycleService.Archive(profileId, version, actorId);
|
|
return Results.Ok(new RiskProfileVersionInfoResponse(versionInfo));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult GetProfileEvents(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromQuery] int limit,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var effectiveLimit = limit > 0 ? limit : 100;
|
|
var events = lifecycleService.GetEvents(profileId, effectiveLimit);
|
|
return Results.Ok(new RiskProfileEventListResponse(profileId, events));
|
|
}
|
|
|
|
private static IResult CompareProfiles(
|
|
HttpContext context,
|
|
[FromBody] CompareRiskProfilesRequest request,
|
|
RiskProfileConfigurationService profileService,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request == null ||
|
|
string.IsNullOrWhiteSpace(request.FromProfileId) ||
|
|
string.IsNullOrWhiteSpace(request.FromVersion) ||
|
|
string.IsNullOrWhiteSpace(request.ToProfileId) ||
|
|
string.IsNullOrWhiteSpace(request.ToVersion))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Both from and to profile IDs and versions are required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var fromProfile = profileService.GetProfile(request.FromProfileId);
|
|
var toProfile = profileService.GetProfile(request.ToProfileId);
|
|
|
|
if (fromProfile == null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"From profile '{request.FromProfileId}' was not found.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
if (toProfile == null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"To profile '{request.ToProfileId}' was not found.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var comparison = lifecycleService.CompareVersions(fromProfile, toProfile);
|
|
return Results.Ok(new RiskProfileComparisonResponse(comparison));
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Comparison failed",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IResult GetProfileHash(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
[FromQuery] bool contentOnly,
|
|
RiskProfileConfigurationService profileService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var profile = profileService.GetProfile(profileId);
|
|
if (profile == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"Risk profile '{profileId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
var hash = contentOnly
|
|
? profileService.ComputeContentHash(profile)
|
|
: profileService.ComputeHash(profile);
|
|
|
|
return Results.Ok(new RiskProfileHashResponse(profile.Id, profile.Version, hash, contentOnly));
|
|
}
|
|
|
|
private static IResult GetProfileMetadata(
|
|
HttpContext context,
|
|
[FromRoute] string profileId,
|
|
RiskProfileConfigurationService profileService,
|
|
RiskProfileLifecycleService lifecycleService)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var profile = profileService.GetProfile(profileId);
|
|
if (profile == null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Profile not found",
|
|
Detail = $"Risk profile '{profileId}' was not found.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
|
|
var versions = lifecycleService.GetAllVersions(profileId);
|
|
var activeVersion = versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
|
|
var hash = profileService.ComputeHash(profile);
|
|
|
|
// Extract signal names and severity thresholds for notification context
|
|
var signalNames = profile.Signals.Select(s => s.Name).ToList();
|
|
var severityThresholds = profile.Overrides.Severity
|
|
.Select(s => new SeverityThresholdInfo(s.Set.ToString(), s.When))
|
|
.ToList();
|
|
|
|
return Results.Ok(new RiskProfileMetadataExportResponse(
|
|
ProfileId: profile.Id,
|
|
Version: profile.Version,
|
|
Description: profile.Description,
|
|
Hash: hash,
|
|
Status: activeVersion?.Status.ToString() ?? "unknown",
|
|
SignalNames: signalNames,
|
|
SeverityThresholds: severityThresholds,
|
|
CustomMetadata: profile.Metadata,
|
|
ExtendsProfile: profile.Extends,
|
|
ExportedAt: DateTime.UtcNow
|
|
));
|
|
}
|
|
|
|
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 RiskProfileListResponse(IReadOnlyList<RiskProfileSummary> Profiles);
|
|
|
|
internal sealed record RiskProfileSummary(string ProfileId, string Version, string? Description);
|
|
|
|
internal sealed record RiskProfileResponse(
|
|
RiskProfileModel Profile,
|
|
string Hash,
|
|
RiskProfileVersionInfo? VersionInfo = null);
|
|
|
|
internal sealed record RiskProfileVersionListResponse(
|
|
string ProfileId,
|
|
IReadOnlyList<RiskProfileVersionInfo> Versions);
|
|
|
|
internal sealed record RiskProfileVersionInfoResponse(RiskProfileVersionInfo VersionInfo);
|
|
|
|
internal sealed record RiskProfileEventListResponse(
|
|
string ProfileId,
|
|
IReadOnlyList<RiskProfileLifecycleEvent> Events);
|
|
|
|
internal sealed record RiskProfileComparisonResponse(RiskProfileVersionComparison Comparison);
|
|
|
|
internal sealed record RiskProfileHashResponse(
|
|
string ProfileId,
|
|
string Version,
|
|
string Hash,
|
|
bool ContentOnly);
|
|
|
|
internal sealed record CreateRiskProfileRequest(RiskProfileModel Profile);
|
|
|
|
internal sealed record DeprecateRiskProfileRequest(string? SuccessorVersion, string? Reason);
|
|
|
|
internal sealed record CompareRiskProfilesRequest(
|
|
string FromProfileId,
|
|
string FromVersion,
|
|
string ToProfileId,
|
|
string ToVersion);
|
|
|
|
/// <summary>
|
|
/// Metadata export response for notification enrichment (POLICY-RISK-40-002).
|
|
/// </summary>
|
|
internal sealed record RiskProfileMetadataExportResponse(
|
|
string ProfileId,
|
|
string Version,
|
|
string? Description,
|
|
string Hash,
|
|
string Status,
|
|
IReadOnlyList<string> SignalNames,
|
|
IReadOnlyList<SeverityThresholdInfo> SeverityThresholds,
|
|
Dictionary<string, object?>? CustomMetadata,
|
|
string? ExtendsProfile,
|
|
DateTime ExportedAt);
|
|
|
|
/// <summary>
|
|
/// Severity threshold information for notification context.
|
|
/// </summary>
|
|
internal sealed record SeverityThresholdInfo(
|
|
string TargetSeverity,
|
|
Dictionary<string, object> WhenConditions);
|
|
|
|
#endregion
|