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:
StellaOps Bot
2025-12-06 13:41:22 +02:00
parent 2141196496
commit 5e514532df
112 changed files with 24861 additions and 211 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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]);
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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");
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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");
}
}
}

View File

@@ -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
});
}
}

View File

@@ -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;
}
}

View File

@@ -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);