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(StatusCodes.Status201Created); group.MapGet(string.Empty, ListPacks) .WithName("ListPolicyPacks") .WithSummary("List policy packs for the current tenant.") .Produces>(StatusCodes.Status200OK); group.MapPost("/{packId}/revisions", CreateRevision) .WithName("CreatePolicyRevision") .WithSummary("Create or update policy revision metadata.") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/{packId}/revisions/{version:int}/bundle", CreateBundle) .WithName("CreatePolicyBundle") .WithSummary("Compile and sign a policy revision bundle for distribution.") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/{packId}/revisions/{version:int}/evaluate", EvaluateRevision) .WithName("EvaluatePolicyRevision") .WithSummary("Evaluate a policy revision deterministically with in-memory caching.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(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(StatusCodes.Status200OK) .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); return endpoints; } private static async Task 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 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 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 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 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 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 Revisions); internal sealed record PolicyPackSummaryDto(string PackId, string? DisplayName, DateTimeOffset CreatedAt, IReadOnlyList 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 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);