using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.Abstractions; using StellaOps.Cryptography; 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(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/download", DownloadBundle) .WithName("DownloadProfileBundle") .WithSummary("Export and download risk profiles as a JSON file.") .Produces(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(StatusCodes.Status200OK) .Produces(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(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(); var notFound = new List(); 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(); 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, ICryptoHash cryptoHash) { 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( cryptoHash: cryptoHash, 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