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(StatusCodes.Status200OK); group.MapGet("/{profileId}", GetProfile) .WithName("GetRiskProfile") .WithSummary("Get a risk profile by ID.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/{profileId}/versions", ListVersions) .WithName("ListRiskProfileVersions") .WithSummary("List all versions of a risk profile.") .Produces(StatusCodes.Status200OK); group.MapGet("/{profileId}/versions/{version}", GetVersion) .WithName("GetRiskProfileVersion") .WithSummary("Get a specific version of a risk profile.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapPost(string.Empty, CreateProfile) .WithName("CreateRiskProfile") .WithSummary("Create a new risk profile version in draft status.") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/{profileId}/versions/{version}:activate", ActivateProfile) .WithName("ActivateRiskProfile") .WithSummary("Activate a draft risk profile, making it available for use.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); group.MapPost("/{profileId}/versions/{version}:deprecate", DeprecateProfile) .WithName("DeprecateRiskProfile") .WithSummary("Deprecate an active risk profile.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); group.MapPost("/{profileId}/versions/{version}:archive", ArchiveProfile) .WithName("ArchiveRiskProfile") .WithSummary("Archive a risk profile, removing it from active use.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/{profileId}/events", GetProfileEvents) .WithName("GetRiskProfileEvents") .WithSummary("Get lifecycle events for a risk profile.") .Produces(StatusCodes.Status200OK); group.MapPost("/compare", CompareProfiles) .WithName("CompareRiskProfiles") .WithSummary("Compare two risk profile versions and list differences.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); group.MapGet("/{profileId}/hash", GetProfileHash) .WithName("GetRiskProfileHash") .WithSummary("Get the deterministic hash of a risk profile.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); group.MapGet("/{profileId}/metadata", GetProfileMetadata) .WithName("GetRiskProfileMetadata") .WithSummary("Export risk profile metadata for notification enrichment (POLICY-RISK-40-002).") .Produces(StatusCodes.Status200OK) .Produces(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 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 Versions); internal sealed record RiskProfileVersionInfoResponse(RiskProfileVersionInfo VersionInfo); internal sealed record RiskProfileEventListResponse( string ProfileId, IReadOnlyList 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); /// /// Metadata export response for notification enrichment (POLICY-RISK-40-002). /// internal sealed record RiskProfileMetadataExportResponse( string ProfileId, string Version, string? Description, string Hash, string Status, IReadOnlyList SignalNames, IReadOnlyList SeverityThresholds, Dictionary? CustomMetadata, string? ExtendsProfile, DateTime ExportedAt); /// /// Severity threshold information for notification context. /// internal sealed record SeverityThresholdInfo( string TargetSeverity, Dictionary WhenConditions); #endregion