Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
380 lines
15 KiB
C#
380 lines
15 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Policy.Engine.Domain;
|
|
using StellaOps.Policy.Engine.Services;
|
|
|
|
namespace StellaOps.Policy.Engine.Endpoints;
|
|
|
|
internal static class PolicyPackEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
var group = endpoints.MapGroup("/api/policy/packs")
|
|
.RequireAuthorization()
|
|
.WithTags("Policy Packs");
|
|
|
|
group.MapPost(string.Empty, CreatePack)
|
|
.WithName("CreatePolicyPack")
|
|
.WithSummary("Create a new policy pack container.")
|
|
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
|
|
|
|
group.MapGet(string.Empty, ListPacks)
|
|
.WithName("ListPolicyPacks")
|
|
.WithSummary("List policy packs for the current tenant.")
|
|
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
|
|
|
|
group.MapPost("/{packId}/revisions", CreateRevision)
|
|
.WithName("CreatePolicyRevision")
|
|
.WithSummary("Create or update policy revision metadata.")
|
|
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{packId}/revisions/{version:int}/bundle", CreateBundle)
|
|
.WithName("CreatePolicyBundle")
|
|
.WithSummary("Compile and sign a policy revision bundle for distribution.")
|
|
.Produces<PolicyBundleResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{packId}/revisions/{version:int}/evaluate", EvaluateRevision)
|
|
.WithName("EvaluatePolicyRevision")
|
|
.WithSummary("Evaluate a policy revision deterministically with in-memory caching.")
|
|
.Produces<PolicyEvaluationResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
|
|
.WithName("ActivatePolicyRevision")
|
|
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
|
|
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
|
|
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
|
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static async Task<IResult> CreatePack(
|
|
HttpContext context,
|
|
[FromBody] CreatePolicyPackRequest request,
|
|
IPolicyPackRepository repository,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Request body is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var packId = string.IsNullOrWhiteSpace(request.PackId)
|
|
? $"pack-{Guid.NewGuid():n}"
|
|
: request.PackId.Trim();
|
|
|
|
var pack = await repository.CreateAsync(packId, request.DisplayName?.Trim(), cancellationToken).ConfigureAwait(false);
|
|
var dto = PolicyPackMapper.ToDto(pack);
|
|
return Results.Created($"/api/policy/packs/{dto.PackId}", dto);
|
|
}
|
|
|
|
private static async Task<IResult> ListPacks(
|
|
HttpContext context,
|
|
IPolicyPackRepository repository,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
var packs = await repository.ListAsync(cancellationToken).ConfigureAwait(false);
|
|
var summaries = packs.Select(PolicyPackMapper.ToSummaryDto).ToArray();
|
|
return Results.Ok(summaries);
|
|
}
|
|
|
|
private static async Task<IResult> CreateRevision(
|
|
HttpContext context,
|
|
[FromRoute] string packId,
|
|
[FromBody] CreatePolicyRevisionRequest request,
|
|
IPolicyPackRepository repository,
|
|
IPolicyActivationSettings activationSettings,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Request body is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
if (request.InitialStatus is not (PolicyRevisionStatus.Draft or PolicyRevisionStatus.Approved))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid status",
|
|
Detail = "Only Draft or Approved statuses are supported for new revisions.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var requiresTwoPersonApproval = activationSettings.ResolveRequirement(request.RequiresTwoPersonApproval);
|
|
|
|
var revision = await repository.UpsertRevisionAsync(
|
|
packId,
|
|
request.Version ?? 0,
|
|
requiresTwoPersonApproval,
|
|
request.InitialStatus,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
return Results.Created(
|
|
$"/api/policy/packs/{packId}/revisions/{revision.Version}",
|
|
PolicyPackMapper.ToDto(packId, revision));
|
|
}
|
|
|
|
private static async Task<IResult> ActivateRevision(
|
|
HttpContext context,
|
|
[FromRoute] string packId,
|
|
[FromRoute] int version,
|
|
[FromBody] ActivatePolicyRevisionRequest request,
|
|
IPolicyPackRepository repository,
|
|
IPolicyActivationAuditor auditor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Request body is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var actorId = ResolveActorId(context);
|
|
if (actorId is null)
|
|
{
|
|
return Results.Problem("Actor identity required.", statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
|
|
var result = await repository.RecordActivationAsync(
|
|
packId,
|
|
version,
|
|
actorId,
|
|
DateTimeOffset.UtcNow,
|
|
request.Comment,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var tenantId = context.User?.FindFirst(StellaOpsClaimTypes.Tenant)?.Value;
|
|
auditor.RecordActivation(packId, version, actorId, tenantId, result, request.Comment);
|
|
|
|
return result.Status switch
|
|
{
|
|
PolicyActivationResultStatus.PackNotFound => Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Policy pack not found",
|
|
Status = StatusCodes.Status404NotFound
|
|
}),
|
|
PolicyActivationResultStatus.RevisionNotFound => Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Policy revision not found",
|
|
Status = StatusCodes.Status404NotFound
|
|
}),
|
|
PolicyActivationResultStatus.NotApproved => Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Revision not approved",
|
|
Detail = "Only approved revisions may be activated.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
}),
|
|
PolicyActivationResultStatus.DuplicateApproval => Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Approval already recorded",
|
|
Detail = "This approver has already approved activation.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
}),
|
|
PolicyActivationResultStatus.PendingSecondApproval => Results.Accepted(
|
|
$"/api/policy/packs/{packId}/revisions/{version}",
|
|
new PolicyRevisionActivationResponse("pending_second_approval", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
|
PolicyActivationResultStatus.Activated => Results.Ok(new PolicyRevisionActivationResponse("activated", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
|
PolicyActivationResultStatus.AlreadyActive => Results.Ok(new PolicyRevisionActivationResponse("already_active", PolicyPackMapper.ToDto(packId, result.Revision!))),
|
|
_ => Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Activation failed",
|
|
Detail = "Unknown activation result.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
})
|
|
};
|
|
}
|
|
|
|
private static async Task<IResult> CreateBundle(
|
|
HttpContext context,
|
|
[FromRoute] string packId,
|
|
[FromRoute] int version,
|
|
[FromBody] PolicyBundleRequest request,
|
|
PolicyBundleService bundleService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Request body is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
var response = await bundleService.CompileAndStoreAsync(packId, version, request, cancellationToken).ConfigureAwait(false);
|
|
if (!response.Success)
|
|
{
|
|
return Results.BadRequest(response);
|
|
}
|
|
|
|
return Results.Created($"/api/policy/packs/{packId}/revisions/{version}/bundle", response);
|
|
}
|
|
|
|
private static async Task<IResult> EvaluateRevision(
|
|
HttpContext context,
|
|
[FromRoute] string packId,
|
|
[FromRoute] int version,
|
|
[FromBody] PolicyEvaluationRequest request,
|
|
PolicyRuntimeEvaluator evaluator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
|
if (scopeResult is not null)
|
|
{
|
|
return scopeResult;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = "Request body is required.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
if (!string.Equals(request.PackId, packId, StringComparison.OrdinalIgnoreCase) || request.Version != version)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Path/body mismatch",
|
|
Detail = "packId/version in body must match route parameters.",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await evaluator.EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(response);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Bundle not found",
|
|
Detail = "Policy bundle must be created before evaluation.",
|
|
Status = StatusCodes.Status404NotFound
|
|
});
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid request",
|
|
Detail = ex.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
internal static class PolicyPackMapper
|
|
{
|
|
public static PolicyPackDto ToDto(PolicyPackRecord record)
|
|
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => ToDto(record.PackId, r)).ToArray());
|
|
|
|
public static PolicyPackSummaryDto ToSummaryDto(PolicyPackRecord record)
|
|
=> new(record.PackId, record.DisplayName, record.CreatedAt, record.GetRevisions().Select(r => r.Version).ToArray());
|
|
|
|
public static PolicyRevisionDto ToDto(string packId, PolicyRevisionRecord revision)
|
|
=> new(
|
|
packId,
|
|
revision.Version,
|
|
revision.Status.ToString(),
|
|
revision.RequiresTwoPersonApproval,
|
|
revision.CreatedAt,
|
|
revision.ActivatedAt,
|
|
revision.Approvals.Select(a => new PolicyActivationApprovalDto(a.ActorId, a.ApprovedAt, a.Comment)).ToArray());
|
|
}
|
|
|
|
internal sealed record CreatePolicyPackRequest(string? PackId, string? DisplayName);
|
|
|
|
internal sealed record PolicyPackDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<PolicyRevisionDto> Revisions);
|
|
|
|
internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList<int> Versions);
|
|
|
|
internal sealed record CreatePolicyRevisionRequest(int? Version, bool? RequiresTwoPersonApproval, PolicyRevisionStatus InitialStatus = PolicyRevisionStatus.Approved);
|
|
|
|
internal sealed record PolicyRevisionDto(string PackId, int Version, string Status, bool RequiresTwoPersonApproval, DateTimeOffset CreatedAt, DateTimeOffset? ActivatedAt, IReadOnlyList<PolicyActivationApprovalDto> Approvals);
|
|
|
|
internal sealed record PolicyActivationApprovalDto(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
|
|
|
internal sealed record ActivatePolicyRevisionRequest(string? Comment);
|
|
|
|
internal sealed record PolicyRevisionActivationResponse(string Status, PolicyRevisionDto Revision);
|