Implement VEX document verification system with issuer management and signature verification
- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation. - Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments. - Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats. - Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats. - Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction. - Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for air-gap notification testing and management.
|
||||
/// </summary>
|
||||
public static class AirGapNotificationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAirGapNotifications(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/system/airgap/notifications");
|
||||
|
||||
group.MapPost("/test", SendTestNotificationAsync)
|
||||
.WithName("AirGap.TestNotification")
|
||||
.WithDescription("Send a test notification")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
|
||||
|
||||
group.MapGet("/channels", GetChannelsAsync)
|
||||
.WithName("AirGap.GetNotificationChannels")
|
||||
.WithDescription("Get configured notification channels")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SendTestNotificationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] TestNotificationRequest? request,
|
||||
IAirGapNotificationService notificationService,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var notification = new AirGapNotification(
|
||||
NotificationId: $"test-{Guid.NewGuid():N}"[..20],
|
||||
TenantId: tenantId,
|
||||
Type: request?.Type ?? AirGapNotificationType.StalenessWarning,
|
||||
Severity: request?.Severity ?? NotificationSeverity.Info,
|
||||
Title: request?.Title ?? "Test Notification",
|
||||
Message: request?.Message ?? "This is a test notification from the air-gap notification system.",
|
||||
OccurredAt: timeProvider.GetUtcNow(),
|
||||
Metadata: new Dictionary<string, object?>
|
||||
{
|
||||
["test"] = true
|
||||
});
|
||||
|
||||
await notificationService.SendAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
sent = true,
|
||||
notification_id = notification.NotificationId,
|
||||
type = notification.Type.ToString(),
|
||||
severity = notification.Severity.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<IResult> GetChannelsAsync(
|
||||
[FromServices] IEnumerable<IAirGapNotificationChannel> channels,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var channelList = channels.Select(c => new
|
||||
{
|
||||
name = c.ChannelName
|
||||
}).ToList();
|
||||
|
||||
return Task.FromResult(Results.Ok(new
|
||||
{
|
||||
channels = channelList,
|
||||
count = channelList.Count
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for sending a test notification.
|
||||
/// </summary>
|
||||
public sealed record TestNotificationRequest(
|
||||
AirGapNotificationType? Type = null,
|
||||
NotificationSeverity? Severity = null,
|
||||
string? Title = null,
|
||||
string? Message = null);
|
||||
@@ -0,0 +1,233 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for attestation reports per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public static class AttestationReportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAttestationReports(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/attestor/reports")
|
||||
.WithTags("Attestation Reports");
|
||||
|
||||
group.MapGet("/{artifactDigest}", GetReportAsync)
|
||||
.WithName("Attestor.GetReport")
|
||||
.WithSummary("Get attestation report for an artifact")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/query", ListReportsAsync)
|
||||
.WithName("Attestor.ListReports")
|
||||
.WithSummary("Query attestation reports")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<AttestationReportListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/verify", VerifyArtifactAsync)
|
||||
.WithName("Attestor.VerifyArtifact")
|
||||
.WithSummary("Generate attestation report for an artifact")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/statistics", GetStatisticsAsync)
|
||||
.WithName("Attestor.GetStatistics")
|
||||
.WithSummary("Get aggregated attestation statistics")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<AttestationStatistics>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/store", StoreReportAsync)
|
||||
.WithName("Attestor.StoreReport")
|
||||
.WithSummary("Store an attestation report")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<StoredAttestationReport>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapDelete("/expired", PurgeExpiredAsync)
|
||||
.WithName("Attestor.PurgeExpired")
|
||||
.WithSummary("Purge expired attestation reports")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<PurgeExpiredResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReportAsync(
|
||||
[FromRoute] string artifactDigest,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Artifact digest is required.",
|
||||
"ERR_ATTEST_010"));
|
||||
}
|
||||
|
||||
var report = await service.GetReportAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (report == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Report not found",
|
||||
$"No attestation report found for artifact '{artifactDigest}'.",
|
||||
"ERR_ATTEST_011"));
|
||||
}
|
||||
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListReportsAsync(
|
||||
[FromBody] AttestationReportQuery? query,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveQuery = query ?? new AttestationReportQuery(
|
||||
ArtifactDigests: null,
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: null,
|
||||
PredicateTypes: null,
|
||||
StatusFilter: null,
|
||||
FromTime: null,
|
||||
ToTime: null,
|
||||
IncludeDetails: true,
|
||||
Limit: 100,
|
||||
Offset: 0);
|
||||
|
||||
var response = await service.ListReportsAsync(effectiveQuery, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyArtifactAsync(
|
||||
[FromBody] VerifyArtifactRequest? request,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Artifact digest is required.",
|
||||
"ERR_ATTEST_010"));
|
||||
}
|
||||
|
||||
var report = await service.GenerateReportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStatisticsAsync(
|
||||
[FromQuery] string? policyIds,
|
||||
[FromQuery] string? predicateTypes,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] DateTimeOffset? fromTime,
|
||||
[FromQuery] DateTimeOffset? toTime,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AttestationReportQuery? filter = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyIds) ||
|
||||
!string.IsNullOrWhiteSpace(predicateTypes) ||
|
||||
!string.IsNullOrWhiteSpace(status) ||
|
||||
fromTime.HasValue ||
|
||||
toTime.HasValue)
|
||||
{
|
||||
filter = new AttestationReportQuery(
|
||||
ArtifactDigests: null,
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: string.IsNullOrWhiteSpace(policyIds) ? null : policyIds.Split(',').ToList(),
|
||||
PredicateTypes: string.IsNullOrWhiteSpace(predicateTypes) ? null : predicateTypes.Split(',').ToList(),
|
||||
StatusFilter: string.IsNullOrWhiteSpace(status)
|
||||
? null
|
||||
: status.Split(',')
|
||||
.Select(s => Enum.TryParse<AttestationReportStatus>(s, true, out var parsed) ? parsed : (AttestationReportStatus?)null)
|
||||
.Where(s => s.HasValue)
|
||||
.Select(s => s!.Value)
|
||||
.ToList(),
|
||||
FromTime: fromTime,
|
||||
ToTime: toTime,
|
||||
IncludeDetails: false,
|
||||
Limit: int.MaxValue,
|
||||
Offset: 0);
|
||||
}
|
||||
|
||||
var statistics = await service.GetStatisticsAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(statistics);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StoreReportAsync(
|
||||
[FromBody] StoreReportRequest? request,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request?.Report == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Report is required.",
|
||||
"ERR_ATTEST_012"));
|
||||
}
|
||||
|
||||
TimeSpan? ttl = request.TtlSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.TtlSeconds.Value)
|
||||
: null;
|
||||
|
||||
var stored = await service.StoreReportAsync(request.Report, ttl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/attestor/reports/{stored.Report.ArtifactDigest}",
|
||||
stored);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PurgeExpiredAsync(
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await service.PurgeExpiredReportsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PurgeExpiredResponse(PurgedCount: count));
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to store an attestation report.
|
||||
/// </summary>
|
||||
public sealed record StoreReportRequest(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("report")] ArtifactAttestationReport Report,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("ttl_seconds")] int? TtlSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Response from purging expired reports.
|
||||
/// </summary>
|
||||
public sealed record PurgeExpiredResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("purged_count")] int PurgedCount);
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Console endpoints for attestation reports per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal static class ConsoleAttestationReportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConsoleAttestationReports(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/policy/console/attestation")
|
||||
.WithTags("Console Attestation Reports")
|
||||
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName);
|
||||
|
||||
group.MapPost("/reports", QueryReportsAsync)
|
||||
.WithName("PolicyEngine.ConsoleAttestationReports")
|
||||
.WithSummary("Query attestation reports for Console")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ConsoleAttestationReportResponse>(StatusCodes.Status200OK)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapPost("/dashboard", GetDashboardAsync)
|
||||
.WithName("PolicyEngine.ConsoleAttestationDashboard")
|
||||
.WithSummary("Get attestation dashboard for Console")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ConsoleAttestationDashboardResponse>(StatusCodes.Status200OK)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/report/{artifactDigest}", GetReportAsync)
|
||||
.WithName("PolicyEngine.ConsoleGetAttestationReport")
|
||||
.WithSummary("Get attestation report for a specific artifact")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ConsoleArtifactReport>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> QueryReportsAsync(
|
||||
[FromBody] ConsoleAttestationReportRequest? request,
|
||||
ConsoleAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["request"] = ["Request body is required."]
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Page < 1)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["page"] = ["Page must be at least 1."]
|
||||
});
|
||||
}
|
||||
|
||||
if (request.PageSize < 1 || request.PageSize > 100)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["pageSize"] = ["Page size must be between 1 and 100."]
|
||||
});
|
||||
}
|
||||
|
||||
var response = await service.QueryReportsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDashboardAsync(
|
||||
[FromBody] ConsoleAttestationDashboardRequest? request,
|
||||
ConsoleAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveRequest = request ?? new ConsoleAttestationDashboardRequest(
|
||||
TimeRange: "24h",
|
||||
PolicyIds: null,
|
||||
ArtifactUriPattern: null);
|
||||
|
||||
var response = await service.GetDashboardAsync(effectiveRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReportAsync(
|
||||
[FromRoute] string artifactDigest,
|
||||
ConsoleAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["artifactDigest"] = ["Artifact digest is required."]
|
||||
});
|
||||
}
|
||||
|
||||
var request = new ConsoleAttestationReportRequest(
|
||||
ArtifactDigests: [artifactDigest],
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: null,
|
||||
PredicateTypes: null,
|
||||
StatusFilter: null,
|
||||
FromTime: null,
|
||||
ToTime: null,
|
||||
GroupBy: null,
|
||||
SortBy: null,
|
||||
Page: 1,
|
||||
PageSize: 1);
|
||||
|
||||
var response = await service.QueryReportsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Reports.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(response.Reports[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for managing effective policies per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
|
||||
/// </summary>
|
||||
internal static class EffectivePolicyEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEffectivePolicies(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/authority/effective-policies")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Effective Policies");
|
||||
|
||||
group.MapPost("/", CreateEffectivePolicy)
|
||||
.WithName("CreateEffectivePolicy")
|
||||
.WithSummary("Create a new effective policy with subject pattern and priority.")
|
||||
.Produces<EffectivePolicyResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/{effectivePolicyId}", GetEffectivePolicy)
|
||||
.WithName("GetEffectivePolicy")
|
||||
.WithSummary("Get an effective policy by ID.")
|
||||
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/{effectivePolicyId}", UpdateEffectivePolicy)
|
||||
.WithName("UpdateEffectivePolicy")
|
||||
.WithSummary("Update an effective policy's priority, expiration, or scopes.")
|
||||
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{effectivePolicyId}", DeleteEffectivePolicy)
|
||||
.WithName("DeleteEffectivePolicy")
|
||||
.WithSummary("Delete an effective policy.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/", ListEffectivePolicies)
|
||||
.WithName("ListEffectivePolicies")
|
||||
.WithSummary("List effective policies with optional filtering.")
|
||||
.Produces<EffectivePolicyListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Scope attachments
|
||||
var scopeGroup = endpoints.MapGroup("/api/v1/authority/scope-attachments")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Authority Scope Attachments");
|
||||
|
||||
scopeGroup.MapPost("/", AttachScope)
|
||||
.WithName("AttachAuthorityScope")
|
||||
.WithSummary("Attach an authorization scope to an effective policy.")
|
||||
.Produces<AuthorityScopeAttachmentResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
scopeGroup.MapDelete("/{attachmentId}", DetachScope)
|
||||
.WithName("DetachAuthorityScope")
|
||||
.WithSummary("Detach an authorization scope.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
scopeGroup.MapGet("/policy/{effectivePolicyId}", GetPolicyScopeAttachments)
|
||||
.WithName("GetPolicyScopeAttachments")
|
||||
.WithSummary("Get all scope attachments for an effective policy.")
|
||||
.Produces<AuthorityScopeAttachmentListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Resolution
|
||||
var resolveGroup = endpoints.MapGroup("/api/v1/authority")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Resolution");
|
||||
|
||||
resolveGroup.MapGet("/resolve", ResolveEffectivePolicy)
|
||||
.WithName("ResolveEffectivePolicy")
|
||||
.WithSummary("Resolve the effective policy for a subject.")
|
||||
.Produces<EffectivePolicyResolutionResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CreateEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromBody] CreateEffectivePolicyRequest request,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var actorId = ResolveActorId(context);
|
||||
var policy = policyService.Create(request, actorId);
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
auditor.RecordCreated(policy, actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/authority/effective-policies/{policy.EffectivePolicyId}",
|
||||
new EffectivePolicyResponse(policy));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", ex.Message, "ERR_AUTH_001"));
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var policy = policyService.Get(effectivePolicyId);
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Effective policy '{effectivePolicyId}' was not found.",
|
||||
"ERR_AUTH_002"));
|
||||
}
|
||||
|
||||
return Results.Ok(new EffectivePolicyResponse(policy));
|
||||
}
|
||||
|
||||
private static IResult UpdateEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
[FromBody] UpdateEffectivePolicyRequest request,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
var policy = policyService.Update(effectivePolicyId, request, actorId);
|
||||
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Effective policy '{effectivePolicyId}' was not found.",
|
||||
"ERR_AUTH_002"));
|
||||
}
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
auditor.RecordUpdated(policy, actorId, request);
|
||||
|
||||
return Results.Ok(new EffectivePolicyResponse(policy));
|
||||
}
|
||||
|
||||
private static IResult DeleteEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!policyService.Delete(effectivePolicyId))
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Effective policy '{effectivePolicyId}' was not found.",
|
||||
"ERR_AUTH_002"));
|
||||
}
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
var actorId = ResolveActorId(context);
|
||||
auditor.RecordDeleted(effectivePolicyId, actorId);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult ListEffectivePolicies(
|
||||
HttpContext context,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] string? policyId,
|
||||
[FromQuery] bool enabledOnly,
|
||||
[FromQuery] bool includeExpired,
|
||||
[FromQuery] int limit,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var query = new EffectivePolicyQuery(
|
||||
TenantId: tenantId,
|
||||
PolicyId: policyId,
|
||||
EnabledOnly: enabledOnly,
|
||||
IncludeExpired: includeExpired,
|
||||
Limit: limit > 0 ? limit : 100);
|
||||
|
||||
var policies = policyService.Query(query);
|
||||
|
||||
return Results.Ok(new EffectivePolicyListResponse(policies, policies.Count));
|
||||
}
|
||||
|
||||
private static IResult AttachScope(
|
||||
HttpContext context,
|
||||
[FromBody] AttachAuthorityScopeRequest request,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var attachment = policyService.AttachScope(request);
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
var actorId = ResolveActorId(context);
|
||||
auditor.RecordScopeAttached(attachment, actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/authority/scope-attachments/{attachment.AttachmentId}",
|
||||
new AuthorityScopeAttachmentResponse(attachment));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
var code = ex.Message.Contains("not found") ? "ERR_AUTH_002" : "ERR_AUTH_004";
|
||||
return Results.BadRequest(CreateProblem("Invalid request", ex.Message, code));
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DetachScope(
|
||||
HttpContext context,
|
||||
[FromRoute] string attachmentId,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!policyService.DetachScope(attachmentId))
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Attachment not found",
|
||||
$"Scope attachment '{attachmentId}' was not found."));
|
||||
}
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
var actorId = ResolveActorId(context);
|
||||
auditor.RecordScopeDetached(attachmentId, actorId);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult GetPolicyScopeAttachments(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var attachments = policyService.GetScopeAttachments(effectivePolicyId);
|
||||
|
||||
return Results.Ok(new AuthorityScopeAttachmentListResponse(attachments));
|
||||
}
|
||||
|
||||
private static IResult ResolveEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromQuery] string subject,
|
||||
[FromQuery] string? tenantId,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Subject is required."));
|
||||
}
|
||||
|
||||
var result = policyService.Resolve(subject, tenantId);
|
||||
|
||||
return Results.Ok(new EffectivePolicyResolutionResponse(result));
|
||||
}
|
||||
|
||||
private static IResult? RequireEffectiveWriteScope(HttpContext context)
|
||||
{
|
||||
// Check for effective:write scope per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
// Primary scope: effective:write (StellaOpsScopes.EffectiveWrite)
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.EffectiveWrite);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
// Fall back to policy:edit for backwards compatibility during migration
|
||||
// TODO: Remove fallback after migration period (track in POLICY-AOC-19-002)
|
||||
return ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
internal sealed record EffectivePolicyResponse(EffectivePolicy EffectivePolicy);
|
||||
|
||||
internal sealed record EffectivePolicyListResponse(IReadOnlyList<EffectivePolicy> Items, int Total);
|
||||
|
||||
internal sealed record AuthorityScopeAttachmentResponse(AuthorityScopeAttachment Attachment);
|
||||
|
||||
internal sealed record AuthorityScopeAttachmentListResponse(IReadOnlyList<AuthorityScopeAttachment> Attachments);
|
||||
|
||||
internal sealed record EffectivePolicyResolutionResponse(EffectivePolicyResolutionResult Result);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,241 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.DeterminismGuard;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for policy code linting and determinism analysis.
|
||||
/// Implements POLICY-AOC-19-001 per docs/modules/policy/design/policy-aoc-linting-rules.md.
|
||||
/// </summary>
|
||||
public static class PolicyLintEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyLint(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/policy/lint");
|
||||
|
||||
group.MapPost("/analyze", AnalyzeSourceAsync)
|
||||
.WithName("Policy.Lint.Analyze")
|
||||
.WithDescription("Analyze source code for determinism violations")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
|
||||
|
||||
group.MapPost("/analyze-batch", AnalyzeBatchAsync)
|
||||
.WithName("Policy.Lint.AnalyzeBatch")
|
||||
.WithDescription("Analyze multiple source files for determinism violations")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
|
||||
|
||||
group.MapGet("/rules", GetLintRulesAsync)
|
||||
.WithName("Policy.Lint.GetRules")
|
||||
.WithDescription("Get available lint rules and their severities")
|
||||
.AllowAnonymous();
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static Task<IResult> AnalyzeSourceAsync(
|
||||
[FromBody] LintSourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
return Task.FromResult(Results.BadRequest(new
|
||||
{
|
||||
error = "LINT_SOURCE_REQUIRED",
|
||||
message = "Source code is required"
|
||||
}));
|
||||
}
|
||||
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = request.EnforceErrors ?? true,
|
||||
FailOnSeverity = ParseSeverity(request.MinSeverity),
|
||||
EnableStaticAnalysis = true,
|
||||
EnableRuntimeMonitoring = false
|
||||
};
|
||||
|
||||
var result = analyzer.AnalyzeSource(request.Source, request.FileName, options);
|
||||
|
||||
return Task.FromResult(Results.Ok(new LintResultResponse
|
||||
{
|
||||
Passed = result.Passed,
|
||||
Violations = result.Violations.Select(MapViolation).ToList(),
|
||||
CountBySeverity = result.CountBySeverity.ToDictionary(
|
||||
kvp => kvp.Key.ToString().ToLowerInvariant(),
|
||||
kvp => kvp.Value),
|
||||
AnalysisDurationMs = result.AnalysisDurationMs,
|
||||
EnforcementEnabled = result.EnforcementEnabled
|
||||
}));
|
||||
}
|
||||
|
||||
private static Task<IResult> AnalyzeBatchAsync(
|
||||
[FromBody] LintBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request?.Files is null || request.Files.Count == 0)
|
||||
{
|
||||
return Task.FromResult(Results.BadRequest(new
|
||||
{
|
||||
error = "LINT_FILES_REQUIRED",
|
||||
message = "At least one file is required"
|
||||
}));
|
||||
}
|
||||
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = request.EnforceErrors ?? true,
|
||||
FailOnSeverity = ParseSeverity(request.MinSeverity),
|
||||
EnableStaticAnalysis = true,
|
||||
EnableRuntimeMonitoring = false
|
||||
};
|
||||
|
||||
var sources = request.Files.Select(f => (f.Source, f.FileName));
|
||||
var result = analyzer.AnalyzeMultiple(sources, options);
|
||||
|
||||
return Task.FromResult(Results.Ok(new LintResultResponse
|
||||
{
|
||||
Passed = result.Passed,
|
||||
Violations = result.Violations.Select(MapViolation).ToList(),
|
||||
CountBySeverity = result.CountBySeverity.ToDictionary(
|
||||
kvp => kvp.Key.ToString().ToLowerInvariant(),
|
||||
kvp => kvp.Value),
|
||||
AnalysisDurationMs = result.AnalysisDurationMs,
|
||||
EnforcementEnabled = result.EnforcementEnabled
|
||||
}));
|
||||
}
|
||||
|
||||
private static Task<IResult> GetLintRulesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var rules = new List<LintRuleInfo>
|
||||
{
|
||||
// Wall-clock rules
|
||||
new("DET-001", "DateTime.Now", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
new("DET-002", "DateTime.UtcNow", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
new("DET-003", "DateTimeOffset.Now", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
new("DET-004", "DateTimeOffset.UtcNow", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
|
||||
// Random/GUID rules
|
||||
new("DET-005", "Guid.NewGuid()", "error", "GuidGeneration", "Use StableIdGenerator or content hash"),
|
||||
new("DET-006", "new Random()", "error", "RandomNumber", "Use seeded random or remove"),
|
||||
new("DET-007", "RandomNumberGenerator", "error", "RandomNumber", "Remove from evaluation path"),
|
||||
|
||||
// Network/Filesystem rules
|
||||
new("DET-008", "HttpClient in eval", "critical", "NetworkAccess", "Remove network from eval path"),
|
||||
new("DET-009", "File.Read* in eval", "critical", "FileSystemAccess", "Remove filesystem from eval path"),
|
||||
|
||||
// Ordering rules
|
||||
new("DET-010", "Dictionary iteration", "warning", "UnstableIteration", "Use OrderBy or SortedDictionary"),
|
||||
new("DET-011", "HashSet iteration", "warning", "UnstableIteration", "Use OrderBy or SortedSet"),
|
||||
|
||||
// Environment rules
|
||||
new("DET-012", "Environment.GetEnvironmentVariable", "error", "EnvironmentAccess", "Use evaluation context"),
|
||||
new("DET-013", "Environment.MachineName", "warning", "EnvironmentAccess", "Remove host-specific info")
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(new
|
||||
{
|
||||
rules,
|
||||
categories = new[]
|
||||
{
|
||||
"WallClock",
|
||||
"RandomNumber",
|
||||
"GuidGeneration",
|
||||
"NetworkAccess",
|
||||
"FileSystemAccess",
|
||||
"EnvironmentAccess",
|
||||
"UnstableIteration",
|
||||
"FloatingPointHazard",
|
||||
"ConcurrencyHazard"
|
||||
},
|
||||
severities = new[] { "info", "warning", "error", "critical" }
|
||||
}));
|
||||
}
|
||||
|
||||
private static DeterminismViolationSeverity ParseSeverity(string? severity)
|
||||
{
|
||||
return severity?.ToLowerInvariant() switch
|
||||
{
|
||||
"info" => DeterminismViolationSeverity.Info,
|
||||
"warning" => DeterminismViolationSeverity.Warning,
|
||||
"error" => DeterminismViolationSeverity.Error,
|
||||
"critical" => DeterminismViolationSeverity.Critical,
|
||||
_ => DeterminismViolationSeverity.Error
|
||||
};
|
||||
}
|
||||
|
||||
private static LintViolationResponse MapViolation(DeterminismViolation v)
|
||||
{
|
||||
return new LintViolationResponse
|
||||
{
|
||||
Category = v.Category.ToString(),
|
||||
ViolationType = v.ViolationType,
|
||||
Message = v.Message,
|
||||
Severity = v.Severity.ToString().ToLowerInvariant(),
|
||||
SourceFile = v.SourceFile,
|
||||
LineNumber = v.LineNumber,
|
||||
MemberName = v.MemberName,
|
||||
Remediation = v.Remediation
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for single source analysis.
|
||||
/// </summary>
|
||||
public sealed record LintSourceRequest(
|
||||
string Source,
|
||||
string? FileName = null,
|
||||
string? MinSeverity = null,
|
||||
bool? EnforceErrors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request for batch source analysis.
|
||||
/// </summary>
|
||||
public sealed record LintBatchRequest(
|
||||
List<LintFileInput> Files,
|
||||
string? MinSeverity = null,
|
||||
bool? EnforceErrors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Single file input for batch analysis.
|
||||
/// </summary>
|
||||
public sealed record LintFileInput(
|
||||
string Source,
|
||||
string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// Response for lint analysis.
|
||||
/// </summary>
|
||||
public sealed record LintResultResponse
|
||||
{
|
||||
public required bool Passed { get; init; }
|
||||
public required List<LintViolationResponse> Violations { get; init; }
|
||||
public required Dictionary<string, int> CountBySeverity { get; init; }
|
||||
public required long AnalysisDurationMs { get; init; }
|
||||
public required bool EnforcementEnabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single violation in lint response.
|
||||
/// </summary>
|
||||
public sealed record LintViolationResponse
|
||||
{
|
||||
public required string Category { get; init; }
|
||||
public required string ViolationType { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public string? SourceFile { get; init; }
|
||||
public int? LineNumber { get; init; }
|
||||
public string? MemberName { get; init; }
|
||||
public string? Remediation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lint rule information.
|
||||
/// </summary>
|
||||
public sealed record LintRuleInfo(
|
||||
string RuleId,
|
||||
string Name,
|
||||
string DefaultSeverity,
|
||||
string Category,
|
||||
string Remediation);
|
||||
@@ -14,11 +14,15 @@ public static class PolicyPackBundleEndpoints
|
||||
|
||||
group.MapPost("", RegisterBundleAsync)
|
||||
.WithName("AirGap.RegisterBundle")
|
||||
.WithDescription("Register a bundle for import");
|
||||
.WithDescription("Register a bundle for import")
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status412PreconditionFailed);
|
||||
|
||||
group.MapGet("{bundleId}", GetBundleStatusAsync)
|
||||
.WithName("AirGap.GetBundleStatus")
|
||||
.WithDescription("Get bundle import status");
|
||||
.WithDescription("Get bundle import status")
|
||||
.ProducesProblem(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("", ListBundlesAsync)
|
||||
.WithName("AirGap.ListBundles")
|
||||
@@ -47,13 +51,24 @@ public static class PolicyPackBundleEndpoints
|
||||
var response = await service.RegisterBundleAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/api/v1/airgap/bundles/{response.ImportId}", response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Bundle import blocked"))
|
||||
{
|
||||
// Sealed-mode enforcement blocked the import
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.ImportBlocked,
|
||||
ex.Message,
|
||||
"Ensure time anchor is fresh before importing bundles");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "INVALID_REQUEST" });
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.BundleInvalid,
|
||||
ex.Message,
|
||||
"Verify request parameters are valid");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public static class RiskProfileAirGapEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskProfileAirGap(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/airgap/risk-profiles")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Air-Gap Risk Profiles");
|
||||
|
||||
group.MapPost("/export", ExportProfilesAsync)
|
||||
.WithName("AirGap.ExportRiskProfiles")
|
||||
.WithSummary("Export risk profiles as an air-gap compatible bundle with signatures.")
|
||||
.Produces<RiskProfileAirGapBundle>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/export/download", DownloadBundleAsync)
|
||||
.WithName("AirGap.DownloadRiskProfileBundle")
|
||||
.WithSummary("Export and download risk profiles as an air-gap compatible JSON file.")
|
||||
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
group.MapPost("/import", ImportProfilesAsync)
|
||||
.WithName("AirGap.ImportRiskProfiles")
|
||||
.WithSummary("Import risk profiles from an air-gap bundle with sealed-mode enforcement.")
|
||||
.Produces<RiskProfileAirGapImportResult>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status412PreconditionFailed);
|
||||
|
||||
group.MapPost("/verify", VerifyBundleAsync)
|
||||
.WithName("AirGap.VerifyRiskProfileBundle")
|
||||
.WithSummary("Verify the integrity of an air-gap bundle without importing.")
|
||||
.Produces<AirGapBundleVerification>(StatusCodes.Status200OK);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportProfilesAsync(
|
||||
HttpContext context,
|
||||
[FromBody] AirGapProfileExportRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileAirGapExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "At least one profile ID is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var profiles = new List<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.Problem(
|
||||
title: "Profiles not found",
|
||||
detail: $"The following profiles were not found: {string.Join(", ", notFound)}",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var exportRequest = new AirGapExportRequest(
|
||||
SignBundle: request.SignBundle,
|
||||
KeyId: request.KeyId,
|
||||
TargetRepository: request.TargetRepository,
|
||||
DisplayName: request.DisplayName);
|
||||
|
||||
var bundle = await exportService.ExportAsync(
|
||||
profiles, exportRequest, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(bundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadBundleAsync(
|
||||
HttpContext context,
|
||||
[FromBody] AirGapProfileExportRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileAirGapExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "At least one profile ID is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var profiles = new List<RiskProfileModel>();
|
||||
|
||||
foreach (var profileId in request.ProfileIds)
|
||||
{
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile != null)
|
||||
{
|
||||
profiles.Add(profile);
|
||||
}
|
||||
}
|
||||
|
||||
var exportRequest = new AirGapExportRequest(
|
||||
SignBundle: request.SignBundle,
|
||||
KeyId: request.KeyId,
|
||||
TargetRepository: request.TargetRepository,
|
||||
DisplayName: request.DisplayName);
|
||||
|
||||
var bundle = await exportService.ExportAsync(
|
||||
profiles, exportRequest, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(bundle, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
var fileName = $"risk-profiles-airgap-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
|
||||
|
||||
return Results.File(bytes, "application/json", fileName);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportProfilesAsync(
|
||||
HttpContext context,
|
||||
[FromBody] AirGapProfileImportRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
RiskProfileAirGapExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.Bundle == null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Bundle is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant ID required",
|
||||
detail: "X-Tenant-Id header is required for air-gap import.",
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
|
||||
}
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: request.VerifySignature,
|
||||
VerifyMerkle: request.VerifyMerkle,
|
||||
EnforceSealedMode: request.EnforceSealedMode,
|
||||
RejectOnSignatureFailure: request.RejectOnSignatureFailure,
|
||||
RejectOnMerkleFailure: request.RejectOnMerkleFailure);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await exportService.ImportAsync(
|
||||
request.Bundle, importRequest, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["errors"] = result.Errors,
|
||||
["signatureVerified"] = result.SignatureVerified,
|
||||
["merkleVerified"] = result.MerkleVerified
|
||||
};
|
||||
|
||||
// Check if it's a sealed-mode enforcement failure
|
||||
if (result.Errors.Any(e => e.Contains("Sealed-mode")))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Import blocked by sealed mode",
|
||||
detail: result.Errors.FirstOrDefault() ?? "Sealed mode enforcement failed",
|
||||
statusCode: 412,
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
return Results.Problem(
|
||||
title: "Import failed",
|
||||
detail: $"Import completed with {result.ErrorCount} errors",
|
||||
statusCode: 400,
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult VerifyBundleAsync(
|
||||
HttpContext context,
|
||||
[FromBody] RiskProfileAirGapBundle bundle,
|
||||
RiskProfileAirGapExportService exportService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Bundle is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var verification = exportService.Verify(bundle);
|
||||
return Results.Ok(verification);
|
||||
}
|
||||
}
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request to export profiles as an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record AirGapProfileExportRequest(
|
||||
IReadOnlyList<string> ProfileIds,
|
||||
bool SignBundle = true,
|
||||
string? KeyId = null,
|
||||
string? TargetRepository = null,
|
||||
string? DisplayName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to import profiles from an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record AirGapProfileImportRequest(
|
||||
RiskProfileAirGapBundle Bundle,
|
||||
bool VerifySignature = true,
|
||||
bool VerifyMerkle = true,
|
||||
bool EnforceSealedMode = true,
|
||||
bool RejectOnSignatureFailure = true,
|
||||
bool RejectOnMerkleFailure = true);
|
||||
|
||||
#endregion
|
||||
@@ -7,6 +7,10 @@ using StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Risk simulation endpoints for Policy Engine and Policy Studio.
|
||||
/// Enhanced with detailed analytics per POLICY-RISK-68-001.
|
||||
/// </summary>
|
||||
internal static class RiskSimulationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
|
||||
@@ -42,6 +46,28 @@ internal static class RiskSimulationEndpoints
|
||||
.Produces<WhatIfSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Policy Studio specific endpoints per POLICY-RISK-68-001
|
||||
group.MapPost("/studio/analyze", RunStudioAnalysis)
|
||||
.WithName("RunPolicyStudioAnalysis")
|
||||
.WithSummary("Run a detailed analysis for Policy Studio with full breakdown analytics.")
|
||||
.WithDescription("Provides comprehensive breakdown including signal analysis, override tracking, score distributions, and component breakdowns for policy authoring.")
|
||||
.Produces<PolicyStudioAnalysisResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/studio/compare", CompareProfilesWithBreakdown)
|
||||
.WithName("CompareProfilesWithBreakdown")
|
||||
.WithSummary("Compare profiles with full breakdown analytics and trend analysis.")
|
||||
.Produces<PolicyStudioComparisonResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/studio/preview", PreviewProfileChanges)
|
||||
.WithName("PreviewProfileChanges")
|
||||
.WithSummary("Preview impact of profile changes before committing.")
|
||||
.WithDescription("Simulates findings against both current and proposed profile to show impact.")
|
||||
.Produces<ProfileChangePreviewResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -355,6 +381,344 @@ internal static class RiskSimulationEndpoints
|
||||
ToHigher: worsened,
|
||||
Unchanged: unchanged));
|
||||
}
|
||||
|
||||
#region Policy Studio Endpoints (POLICY-RISK-68-001)
|
||||
|
||||
private static IResult RunStudioAnalysis(
|
||||
HttpContext context,
|
||||
[FromBody] PolicyStudioAnalysisRequest 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 breakdownOptions = request.BreakdownOptions ?? RiskSimulationBreakdownOptions.Default;
|
||||
var result = simulationService.SimulateWithBreakdown(
|
||||
new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full),
|
||||
breakdownOptions);
|
||||
|
||||
return Results.Ok(new PolicyStudioAnalysisResponse(
|
||||
Result: result.Result,
|
||||
Breakdown: result.Breakdown,
|
||||
TotalExecutionTimeMs: result.TotalExecutionTimeMs));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Service unavailable",
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CompareProfilesWithBreakdown(
|
||||
HttpContext context,
|
||||
[FromBody] PolicyStudioComparisonRequest 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
|
||||
});
|
||||
}
|
||||
|
||||
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.CompareProfilesWithBreakdown(
|
||||
request.BaseProfileId,
|
||||
request.CompareProfileId,
|
||||
request.Findings,
|
||||
request.BreakdownOptions);
|
||||
|
||||
return Results.Ok(new PolicyStudioComparisonResponse(
|
||||
BaselineResult: result.BaselineResult,
|
||||
CompareResult: result.CompareResult,
|
||||
Breakdown: result.Breakdown,
|
||||
ExecutionTimeMs: result.ExecutionTimeMs));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Service unavailable",
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult PreviewProfileChanges(
|
||||
HttpContext context,
|
||||
[FromBody] ProfileChangePreviewRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.CurrentProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "CurrentProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ProposedProfileId) &&
|
||||
(request.ProposedWeightChanges == null || request.ProposedWeightChanges.Count == 0) &&
|
||||
(request.ProposedOverrideChanges == null || request.ProposedOverrideChanges.Count == 0))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Either ProposedProfileId or at least one proposed change is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Run simulation against current profile
|
||||
var currentRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.CurrentProfileId,
|
||||
ProfileVersion: request.CurrentProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var currentResult = simulationService.Simulate(currentRequest);
|
||||
|
||||
RiskSimulationResult proposedResult;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ProposedProfileId))
|
||||
{
|
||||
// Compare against existing proposed profile
|
||||
var proposedRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProposedProfileId,
|
||||
ProfileVersion: request.ProposedProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
proposedResult = simulationService.Simulate(proposedRequest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Inline changes not yet supported - return preview of current only
|
||||
proposedResult = currentResult;
|
||||
}
|
||||
|
||||
var impactSummary = ComputePreviewImpact(currentResult, proposedResult);
|
||||
|
||||
return Results.Ok(new ProfileChangePreviewResponse(
|
||||
CurrentResult: new ProfileSimulationSummary(
|
||||
currentResult.ProfileId,
|
||||
currentResult.ProfileVersion,
|
||||
currentResult.AggregateMetrics),
|
||||
ProposedResult: new ProfileSimulationSummary(
|
||||
proposedResult.ProfileId,
|
||||
proposedResult.ProfileVersion,
|
||||
proposedResult.AggregateMetrics),
|
||||
Impact: impactSummary,
|
||||
HighImpactFindings: ComputeHighImpactFindings(currentResult, proposedResult)));
|
||||
}
|
||||
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 ProfileChangeImpact ComputePreviewImpact(
|
||||
RiskSimulationResult current,
|
||||
RiskSimulationResult proposed)
|
||||
{
|
||||
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
|
||||
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
|
||||
|
||||
var improved = 0;
|
||||
var worsened = 0;
|
||||
var unchanged = 0;
|
||||
var severityEscalations = 0;
|
||||
var severityDeescalations = 0;
|
||||
var actionChanges = 0;
|
||||
|
||||
foreach (var (findingId, currentScore) in currentScores)
|
||||
{
|
||||
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
|
||||
continue;
|
||||
|
||||
var scoreDelta = proposedScore.NormalizedScore - currentScore.NormalizedScore;
|
||||
if (Math.Abs(scoreDelta) < 1.0)
|
||||
unchanged++;
|
||||
else if (scoreDelta < 0)
|
||||
improved++;
|
||||
else
|
||||
worsened++;
|
||||
|
||||
if (proposedScore.Severity > currentScore.Severity)
|
||||
severityEscalations++;
|
||||
else if (proposedScore.Severity < currentScore.Severity)
|
||||
severityDeescalations++;
|
||||
|
||||
if (proposedScore.RecommendedAction != currentScore.RecommendedAction)
|
||||
actionChanges++;
|
||||
}
|
||||
|
||||
return new ProfileChangeImpact(
|
||||
FindingsImproved: improved,
|
||||
FindingsWorsened: worsened,
|
||||
FindingsUnchanged: unchanged,
|
||||
SeverityEscalations: severityEscalations,
|
||||
SeverityDeescalations: severityDeescalations,
|
||||
ActionChanges: actionChanges,
|
||||
MeanScoreDelta: proposed.AggregateMetrics.MeanScore - current.AggregateMetrics.MeanScore,
|
||||
CriticalCountDelta: proposed.AggregateMetrics.CriticalCount - current.AggregateMetrics.CriticalCount,
|
||||
HighCountDelta: proposed.AggregateMetrics.HighCount - current.AggregateMetrics.HighCount);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<HighImpactFindingPreview> ComputeHighImpactFindings(
|
||||
RiskSimulationResult current,
|
||||
RiskSimulationResult proposed)
|
||||
{
|
||||
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
|
||||
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
|
||||
|
||||
var highImpact = new List<HighImpactFindingPreview>();
|
||||
|
||||
foreach (var (findingId, currentScore) in currentScores)
|
||||
{
|
||||
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
|
||||
continue;
|
||||
|
||||
var scoreDelta = Math.Abs(proposedScore.NormalizedScore - currentScore.NormalizedScore);
|
||||
var severityChanged = proposedScore.Severity != currentScore.Severity;
|
||||
var actionChanged = proposedScore.RecommendedAction != currentScore.RecommendedAction;
|
||||
|
||||
if (scoreDelta > 10 || severityChanged || actionChanged)
|
||||
{
|
||||
highImpact.Add(new HighImpactFindingPreview(
|
||||
FindingId: findingId,
|
||||
CurrentScore: currentScore.NormalizedScore,
|
||||
ProposedScore: proposedScore.NormalizedScore,
|
||||
ScoreDelta: proposedScore.NormalizedScore - currentScore.NormalizedScore,
|
||||
CurrentSeverity: currentScore.Severity.ToString(),
|
||||
ProposedSeverity: proposedScore.Severity.ToString(),
|
||||
CurrentAction: currentScore.RecommendedAction.ToString(),
|
||||
ProposedAction: proposedScore.RecommendedAction.ToString(),
|
||||
ImpactReason: DetermineImpactReason(currentScore, proposedScore)));
|
||||
}
|
||||
}
|
||||
|
||||
return highImpact
|
||||
.OrderByDescending(f => Math.Abs(f.ScoreDelta))
|
||||
.Take(20)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string DetermineImpactReason(FindingScore current, FindingScore proposed)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
if (proposed.Severity != current.Severity)
|
||||
{
|
||||
reasons.Add($"Severity {(proposed.Severity > current.Severity ? "escalated" : "deescalated")} from {current.Severity} to {proposed.Severity}");
|
||||
}
|
||||
|
||||
if (proposed.RecommendedAction != current.RecommendedAction)
|
||||
{
|
||||
reasons.Add($"Action changed from {current.RecommendedAction} to {proposed.RecommendedAction}");
|
||||
}
|
||||
|
||||
var scoreDelta = proposed.NormalizedScore - current.NormalizedScore;
|
||||
if (Math.Abs(scoreDelta) > 10)
|
||||
{
|
||||
reasons.Add($"Score {(scoreDelta > 0 ? "increased" : "decreased")} by {Math.Abs(scoreDelta):F1} points");
|
||||
}
|
||||
|
||||
return reasons.Count > 0 ? string.Join("; ", reasons) : "Significant score change";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
@@ -433,3 +797,73 @@ internal sealed record SeverityShifts(
|
||||
int Unchanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Studio DTOs (POLICY-RISK-68-001)
|
||||
|
||||
internal sealed record PolicyStudioAnalysisRequest(
|
||||
string ProfileId,
|
||||
string? ProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
RiskSimulationBreakdownOptions? BreakdownOptions = null);
|
||||
|
||||
internal sealed record PolicyStudioAnalysisResponse(
|
||||
RiskSimulationResult Result,
|
||||
RiskSimulationBreakdown Breakdown,
|
||||
double TotalExecutionTimeMs);
|
||||
|
||||
internal sealed record PolicyStudioComparisonRequest(
|
||||
string BaseProfileId,
|
||||
string CompareProfileId,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
RiskSimulationBreakdownOptions? BreakdownOptions = null);
|
||||
|
||||
internal sealed record PolicyStudioComparisonResponse(
|
||||
RiskSimulationResult BaselineResult,
|
||||
RiskSimulationResult CompareResult,
|
||||
RiskSimulationBreakdown Breakdown,
|
||||
double ExecutionTimeMs);
|
||||
|
||||
internal sealed record ProfileChangePreviewRequest(
|
||||
string CurrentProfileId,
|
||||
string? CurrentProfileVersion,
|
||||
string? ProposedProfileId,
|
||||
string? ProposedProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
IReadOnlyDictionary<string, double>? ProposedWeightChanges = null,
|
||||
IReadOnlyList<ProposedOverrideChange>? ProposedOverrideChanges = null);
|
||||
|
||||
internal sealed record ProposedOverrideChange(
|
||||
string OverrideType,
|
||||
Dictionary<string, object> When,
|
||||
object Value,
|
||||
string? Reason = null);
|
||||
|
||||
internal sealed record ProfileChangePreviewResponse(
|
||||
ProfileSimulationSummary CurrentResult,
|
||||
ProfileSimulationSummary ProposedResult,
|
||||
ProfileChangeImpact Impact,
|
||||
IReadOnlyList<HighImpactFindingPreview> HighImpactFindings);
|
||||
|
||||
internal sealed record ProfileChangeImpact(
|
||||
int FindingsImproved,
|
||||
int FindingsWorsened,
|
||||
int FindingsUnchanged,
|
||||
int SeverityEscalations,
|
||||
int SeverityDeescalations,
|
||||
int ActionChanges,
|
||||
double MeanScoreDelta,
|
||||
int CriticalCountDelta,
|
||||
int HighCountDelta);
|
||||
|
||||
internal sealed record HighImpactFindingPreview(
|
||||
string FindingId,
|
||||
double CurrentScore,
|
||||
double ProposedScore,
|
||||
double ScoreDelta,
|
||||
string CurrentSeverity,
|
||||
string ProposedSeverity,
|
||||
string CurrentAction,
|
||||
string ProposedAction,
|
||||
string ImpactReason);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for sealed-mode operations per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public static class SealedModeEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSealedMode(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/system/airgap");
|
||||
|
||||
group.MapPost("/seal", SealAsync)
|
||||
.WithName("AirGap.Seal")
|
||||
.WithDescription("Seal the environment")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
group.MapPost("/unseal", UnsealAsync)
|
||||
.WithName("AirGap.Unseal")
|
||||
.WithDescription("Unseal the environment")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
group.MapGet("/status", GetStatusAsync)
|
||||
.WithName("AirGap.GetStatus")
|
||||
.WithDescription("Get sealed-mode status")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
|
||||
|
||||
group.MapPost("/verify", VerifyBundleAsync)
|
||||
.WithName("AirGap.VerifyBundle")
|
||||
.WithDescription("Verify a bundle against trust roots")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:verify"))
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SealAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] SealRequest request,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await service.SealAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.SealFailed,
|
||||
ex.Message,
|
||||
"Ensure all required parameters are provided");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.SealFailed,
|
||||
$"Seal operation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnsealAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await service.UnsealAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.UnsealFailed,
|
||||
$"Unseal operation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStatusAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var status = await service.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyBundleAsync(
|
||||
[FromBody] BundleVerifyRequest request,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await service.VerifyBundleAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Return problem details if verification failed
|
||||
if (!response.Valid && response.VerificationResult.Error is not null)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.SignatureInvalid,
|
||||
response.VerificationResult.Error,
|
||||
"Verify bundle integrity and trust root configuration",
|
||||
422);
|
||||
}
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.BundleInvalid,
|
||||
ex.Message,
|
||||
"Ensure bundle path is valid and accessible");
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.BundleInvalid,
|
||||
$"Bundle file not found: {ex.FileName ?? ex.Message}",
|
||||
"Verify the bundle path is correct");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for staleness signaling and fallback status per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public static class StalenessEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapStalenessSignaling(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/system/airgap/staleness");
|
||||
|
||||
group.MapGet("/status", GetStalenessStatusAsync)
|
||||
.WithName("AirGap.GetStalenessStatus")
|
||||
.WithDescription("Get staleness signal status for health monitoring");
|
||||
|
||||
group.MapGet("/fallback", GetFallbackStatusAsync)
|
||||
.WithName("AirGap.GetFallbackStatus")
|
||||
.WithDescription("Get fallback mode status and configuration");
|
||||
|
||||
group.MapPost("/evaluate", EvaluateStalenessAsync)
|
||||
.WithName("AirGap.EvaluateStaleness")
|
||||
.WithDescription("Trigger staleness evaluation and signaling")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
|
||||
|
||||
group.MapPost("/recover", SignalRecoveryAsync)
|
||||
.WithName("AirGap.SignalRecovery")
|
||||
.WithDescription("Signal staleness recovery after time anchor refresh")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStalenessStatusAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var status = await service.GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Return different status codes based on health
|
||||
if (status.IsBreach)
|
||||
{
|
||||
return Results.Json(status, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (status.HasWarning)
|
||||
{
|
||||
// Return 200 but with warning headers
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFallbackStatusAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var config = await service.GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var isActive = await service.IsFallbackActiveAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
fallbackActive = isActive,
|
||||
configuration = config
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateStalenessAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
await service.EvaluateAndSignalAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var status = await service.GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
evaluated = true,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> SignalRecoveryAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
await service.SignalRecoveryAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
recovered = true,
|
||||
tenantId
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Editor endpoints for verification policy management per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public static class VerificationPolicyEditorEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapVerificationPolicyEditor(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/attestor/policies/editor")
|
||||
.WithTags("Verification Policy Editor");
|
||||
|
||||
group.MapGet("/metadata", GetEditorMetadata)
|
||||
.WithName("Attestor.GetEditorMetadata")
|
||||
.WithSummary("Get editor metadata for verification policy forms")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicyEditorMetadata>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/validate", ValidatePolicyAsync)
|
||||
.WithName("Attestor.ValidatePolicy")
|
||||
.WithSummary("Validate a verification policy without persisting")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ValidatePolicyResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{policyId}", GetPolicyEditorViewAsync)
|
||||
.WithName("Attestor.GetPolicyEditorView")
|
||||
.WithSummary("Get a verification policy with editor metadata")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicyEditorView>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/clone", ClonePolicyAsync)
|
||||
.WithName("Attestor.ClonePolicy")
|
||||
.WithSummary("Clone a verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapPost("/compare", ComparePoliciesAsync)
|
||||
.WithName("Attestor.ComparePolicies")
|
||||
.WithSummary("Compare two verification policies")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ComparePoliciesResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetEditorMetadata()
|
||||
{
|
||||
var metadata = VerificationPolicyEditorMetadataProvider.GetMetadata();
|
||||
return Results.Ok(metadata);
|
||||
}
|
||||
|
||||
private static IResult ValidatePolicyAsync(
|
||||
[FromBody] ValidatePolicyRequest request,
|
||||
VerificationPolicyValidator validator)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.Ok(new ValidatePolicyResponse(
|
||||
Valid: false,
|
||||
Errors: [new VerificationPolicyValidationError("ERR_VP_000", "request", "Request body is required.")],
|
||||
Warnings: [],
|
||||
Suggestions: []));
|
||||
}
|
||||
|
||||
// Convert to CreateVerificationPolicyRequest for validation
|
||||
var createRequest = new CreateVerificationPolicyRequest(
|
||||
PolicyId: request.PolicyId ?? string.Empty,
|
||||
Version: request.Version ?? "1.0.0",
|
||||
Description: request.Description,
|
||||
TenantScope: request.TenantScope,
|
||||
PredicateTypes: request.PredicateTypes ?? [],
|
||||
SignerRequirements: request.SignerRequirements,
|
||||
ValidityWindow: request.ValidityWindow,
|
||||
Metadata: request.Metadata);
|
||||
|
||||
var validation = validator.ValidateCreate(createRequest);
|
||||
|
||||
var errors = validation.Errors
|
||||
.Where(e => e.Severity == ValidationSeverity.Error)
|
||||
.ToList();
|
||||
|
||||
var warnings = validation.Errors
|
||||
.Where(e => e.Severity == ValidationSeverity.Warning)
|
||||
.ToList();
|
||||
|
||||
var suggestions = VerificationPolicyEditorMetadataProvider.GenerateSuggestions(createRequest, validation);
|
||||
|
||||
return Results.Ok(new ValidatePolicyResponse(
|
||||
Valid: errors.Count == 0,
|
||||
Errors: errors,
|
||||
Warnings: warnings,
|
||||
Suggestions: suggestions));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyEditorViewAsync(
|
||||
[FromRoute] string policyId,
|
||||
IVerificationPolicyStore store,
|
||||
VerificationPolicyValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = await store.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
// Re-validate current policy state
|
||||
var updateRequest = new UpdateVerificationPolicyRequest(
|
||||
Version: policy.Version,
|
||||
Description: policy.Description,
|
||||
PredicateTypes: policy.PredicateTypes,
|
||||
SignerRequirements: policy.SignerRequirements,
|
||||
ValidityWindow: policy.ValidityWindow,
|
||||
Metadata: policy.Metadata);
|
||||
|
||||
var validation = validator.ValidateUpdate(updateRequest);
|
||||
|
||||
// Generate suggestions
|
||||
var createRequest = new CreateVerificationPolicyRequest(
|
||||
PolicyId: policy.PolicyId,
|
||||
Version: policy.Version,
|
||||
Description: policy.Description,
|
||||
TenantScope: policy.TenantScope,
|
||||
PredicateTypes: policy.PredicateTypes,
|
||||
SignerRequirements: policy.SignerRequirements,
|
||||
ValidityWindow: policy.ValidityWindow,
|
||||
Metadata: policy.Metadata);
|
||||
|
||||
var suggestions = VerificationPolicyEditorMetadataProvider.GenerateSuggestions(createRequest, validation);
|
||||
|
||||
// TODO: Check if policy is referenced by attestations
|
||||
var isReferenced = false;
|
||||
|
||||
var view = new VerificationPolicyEditorView(
|
||||
Policy: policy,
|
||||
Validation: validation,
|
||||
Suggestions: suggestions,
|
||||
CanDelete: !isReferenced,
|
||||
IsReferenced: isReferenced);
|
||||
|
||||
return Results.Ok(view);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClonePolicyAsync(
|
||||
[FromBody] ClonePolicyRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
VerificationPolicyValidator validator,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SourcePolicyId))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Source policy ID is required.",
|
||||
"ERR_ATTEST_006"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPolicyId))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"New policy ID is required.",
|
||||
"ERR_ATTEST_007"));
|
||||
}
|
||||
|
||||
var sourcePolicy = await store.GetAsync(request.SourcePolicyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (sourcePolicy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Source policy not found",
|
||||
$"Policy '{request.SourcePolicyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
if (await store.ExistsAsync(request.NewPolicyId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.Conflict(CreateProblem(
|
||||
"Policy exists",
|
||||
$"Policy '{request.NewPolicyId}' already exists.",
|
||||
"ERR_ATTEST_004"));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var clonedPolicy = new VerificationPolicy(
|
||||
PolicyId: request.NewPolicyId,
|
||||
Version: request.NewVersion ?? sourcePolicy.Version,
|
||||
Description: sourcePolicy.Description != null
|
||||
? $"Cloned from {request.SourcePolicyId}: {sourcePolicy.Description}"
|
||||
: $"Cloned from {request.SourcePolicyId}",
|
||||
TenantScope: sourcePolicy.TenantScope,
|
||||
PredicateTypes: sourcePolicy.PredicateTypes,
|
||||
SignerRequirements: sourcePolicy.SignerRequirements,
|
||||
ValidityWindow: sourcePolicy.ValidityWindow,
|
||||
Metadata: sourcePolicy.Metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now);
|
||||
|
||||
await store.CreateAsync(clonedPolicy, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/attestor/policies/{clonedPolicy.PolicyId}",
|
||||
clonedPolicy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ComparePoliciesAsync(
|
||||
[FromBody] ComparePoliciesRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyIdA))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Policy ID A is required.",
|
||||
"ERR_ATTEST_008"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyIdB))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Policy ID B is required.",
|
||||
"ERR_ATTEST_009"));
|
||||
}
|
||||
|
||||
var policyA = await store.GetAsync(request.PolicyIdA, cancellationToken).ConfigureAwait(false);
|
||||
var policyB = await store.GetAsync(request.PolicyIdB, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policyA == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{request.PolicyIdA}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
if (policyB == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{request.PolicyIdB}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
var differences = ComputeDifferences(policyA, policyB);
|
||||
|
||||
return Results.Ok(new ComparePoliciesResponse(
|
||||
PolicyA: policyA,
|
||||
PolicyB: policyB,
|
||||
Differences: differences));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PolicyDifference> ComputeDifferences(VerificationPolicy a, VerificationPolicy b)
|
||||
{
|
||||
var differences = new List<PolicyDifference>();
|
||||
|
||||
if (a.Version != b.Version)
|
||||
{
|
||||
differences.Add(new PolicyDifference("version", a.Version, b.Version, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (a.Description != b.Description)
|
||||
{
|
||||
differences.Add(new PolicyDifference("description", a.Description, b.Description, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (a.TenantScope != b.TenantScope)
|
||||
{
|
||||
differences.Add(new PolicyDifference("tenant_scope", a.TenantScope, b.TenantScope, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
// Compare predicate types
|
||||
var predicateTypesA = a.PredicateTypes.ToHashSet();
|
||||
var predicateTypesB = b.PredicateTypes.ToHashSet();
|
||||
|
||||
foreach (var added in predicateTypesB.Except(predicateTypesA))
|
||||
{
|
||||
differences.Add(new PolicyDifference("predicate_types", null, added, DifferenceType.Added));
|
||||
}
|
||||
|
||||
foreach (var removed in predicateTypesA.Except(predicateTypesB))
|
||||
{
|
||||
differences.Add(new PolicyDifference("predicate_types", removed, null, DifferenceType.Removed));
|
||||
}
|
||||
|
||||
// Compare signer requirements
|
||||
if (a.SignerRequirements.MinimumSignatures != b.SignerRequirements.MinimumSignatures)
|
||||
{
|
||||
differences.Add(new PolicyDifference(
|
||||
"signer_requirements.minimum_signatures",
|
||||
a.SignerRequirements.MinimumSignatures,
|
||||
b.SignerRequirements.MinimumSignatures,
|
||||
DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (a.SignerRequirements.RequireRekor != b.SignerRequirements.RequireRekor)
|
||||
{
|
||||
differences.Add(new PolicyDifference(
|
||||
"signer_requirements.require_rekor",
|
||||
a.SignerRequirements.RequireRekor,
|
||||
b.SignerRequirements.RequireRekor,
|
||||
DifferenceType.Modified));
|
||||
}
|
||||
|
||||
// Compare fingerprints
|
||||
var fingerprintsA = a.SignerRequirements.TrustedKeyFingerprints.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var fingerprintsB = b.SignerRequirements.TrustedKeyFingerprints.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var added in fingerprintsB.Except(fingerprintsA))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.trusted_key_fingerprints", null, added, DifferenceType.Added));
|
||||
}
|
||||
|
||||
foreach (var removed in fingerprintsA.Except(fingerprintsB))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.trusted_key_fingerprints", removed, null, DifferenceType.Removed));
|
||||
}
|
||||
|
||||
// Compare algorithms
|
||||
var algorithmsA = (a.SignerRequirements.Algorithms ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var algorithmsB = (b.SignerRequirements.Algorithms ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var added in algorithmsB.Except(algorithmsA))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.algorithms", null, added, DifferenceType.Added));
|
||||
}
|
||||
|
||||
foreach (var removed in algorithmsA.Except(algorithmsB))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.algorithms", removed, null, DifferenceType.Removed));
|
||||
}
|
||||
|
||||
// Compare validity window
|
||||
var validityA = a.ValidityWindow;
|
||||
var validityB = b.ValidityWindow;
|
||||
|
||||
if (validityA == null && validityB != null)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window", null, validityB, DifferenceType.Added));
|
||||
}
|
||||
else if (validityA != null && validityB == null)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window", validityA, null, DifferenceType.Removed));
|
||||
}
|
||||
else if (validityA != null && validityB != null)
|
||||
{
|
||||
if (validityA.NotBefore != validityB.NotBefore)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window.not_before", validityA.NotBefore, validityB.NotBefore, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (validityA.NotAfter != validityB.NotAfter)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window.not_after", validityA.NotAfter, validityB.NotAfter, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (validityA.MaxAttestationAge != validityB.MaxAttestationAge)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window.max_attestation_age", validityA.MaxAttestationAge, validityB.MaxAttestationAge, DifferenceType.Modified));
|
||||
}
|
||||
}
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for verification policy management per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public static class VerificationPolicyEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapVerificationPolicies(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/attestor/policies")
|
||||
.WithTags("Verification Policies");
|
||||
|
||||
group.MapPost("/", CreatePolicyAsync)
|
||||
.WithName("Attestor.CreatePolicy")
|
||||
.WithSummary("Create a new verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapGet("/{policyId}", GetPolicyAsync)
|
||||
.WithName("Attestor.GetPolicy")
|
||||
.WithSummary("Get a verification policy by ID")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/", ListPoliciesAsync)
|
||||
.WithName("Attestor.ListPolicies")
|
||||
.WithSummary("List verification policies")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicyListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPut("/{policyId}", UpdatePolicyAsync)
|
||||
.WithName("Attestor.UpdatePolicy")
|
||||
.WithSummary("Update a verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{policyId}", DeletePolicyAsync)
|
||||
.WithName("Attestor.DeletePolicy")
|
||||
.WithSummary("Delete a verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePolicyAsync(
|
||||
[FromBody] CreateVerificationPolicyRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Policy ID is required.",
|
||||
"ERR_ATTEST_002"));
|
||||
}
|
||||
|
||||
if (request.PredicateTypes == null || request.PredicateTypes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"At least one predicate type is required.",
|
||||
"ERR_ATTEST_003"));
|
||||
}
|
||||
|
||||
if (await store.ExistsAsync(request.PolicyId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.Conflict(CreateProblem(
|
||||
"Policy exists",
|
||||
$"Policy '{request.PolicyId}' already exists.",
|
||||
"ERR_ATTEST_004"));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var policy = new VerificationPolicy(
|
||||
PolicyId: request.PolicyId,
|
||||
Version: request.Version ?? "1.0.0",
|
||||
Description: request.Description,
|
||||
TenantScope: request.TenantScope ?? "*",
|
||||
PredicateTypes: request.PredicateTypes,
|
||||
SignerRequirements: request.SignerRequirements ?? SignerRequirements.Default,
|
||||
ValidityWindow: request.ValidityWindow,
|
||||
Metadata: request.Metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now);
|
||||
|
||||
await store.CreateAsync(policy, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/attestor/policies/{policy.PolicyId}",
|
||||
policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyAsync(
|
||||
[FromRoute] string policyId,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = await store.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPoliciesAsync(
|
||||
[FromQuery] string? tenantScope,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policies = await store.ListAsync(tenantScope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new VerificationPolicyListResponse(
|
||||
Policies: policies,
|
||||
Total: policies.Count));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePolicyAsync(
|
||||
[FromRoute] string policyId,
|
||||
[FromBody] UpdateVerificationPolicyRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var updated = await store.UpdateAsync(
|
||||
policyId,
|
||||
existing => existing with
|
||||
{
|
||||
Version = request.Version ?? existing.Version,
|
||||
Description = request.Description ?? existing.Description,
|
||||
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
|
||||
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
|
||||
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
|
||||
Metadata = request.Metadata ?? existing.Metadata,
|
||||
UpdatedAt = now
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (updated == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePolicyAsync(
|
||||
[FromRoute] string policyId,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await store.DeleteAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing verification policies.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyListResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("policies")] IReadOnlyList<VerificationPolicy> Policies,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("total")] int Total);
|
||||
Reference in New Issue
Block a user