Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// <copyright file="PlanAdminEndpoints.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoint mappings for Plan Rule administration.
|
||||
/// </summary>
|
||||
public static class PlanAdminEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps the plan admin endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapPlanAdminEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/admin/plans")
|
||||
.WithTags("Plan Administration")
|
||||
.RequireAuthorization("registry.admin");
|
||||
|
||||
group.MapGet("/", ListPlans)
|
||||
.WithName("ListPlans")
|
||||
.WithDescription("Lists all plan rules ordered by name.")
|
||||
.Produces<IReadOnlyList<PlanRuleDto>>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapGet("/{planId}", GetPlan)
|
||||
.WithName("GetPlan")
|
||||
.WithDescription("Gets a plan rule by ID.")
|
||||
.Produces<PlanRuleDto>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/", CreatePlan)
|
||||
.WithName("CreatePlan")
|
||||
.WithDescription("Creates a new plan rule.")
|
||||
.Produces<PlanRuleDto>(StatusCodes.Status201Created)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPut("/{planId}", UpdatePlan)
|
||||
.WithName("UpdatePlan")
|
||||
.WithDescription("Updates an existing plan rule. Requires version for optimistic concurrency.")
|
||||
.Produces<PlanRuleDto>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapDelete("/{planId}", DeletePlan)
|
||||
.WithName("DeletePlan")
|
||||
.WithDescription("Deletes a plan rule.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesProblem(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/validate", ValidatePlan)
|
||||
.WithName("ValidatePlan")
|
||||
.WithDescription("Validates a plan rule without saving. Optionally evaluates test scopes.")
|
||||
.Produces<ValidationResult>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapGet("/audit", GetAuditHistory)
|
||||
.WithName("GetPlanAuditHistory")
|
||||
.WithDescription("Gets audit history for plan changes. Optionally filter by plan ID.")
|
||||
.Produces<PaginatedResponse<PlanAuditEntry>>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
private static async Task<Ok<IReadOnlyList<PlanRuleDto>>> ListPlans(
|
||||
[FromServices] IPlanRuleStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plans = await store.GetAllAsync(cancellationToken);
|
||||
return TypedResults.Ok(plans);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PlanRuleDto>, NotFound<ProblemDetails>>> GetPlan(
|
||||
string planId,
|
||||
[FromServices] IPlanRuleStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await store.GetByIdAsync(planId, cancellationToken);
|
||||
if (plan is null)
|
||||
{
|
||||
return TypedResults.NotFound(CreateProblemDetails(
|
||||
StatusCodes.Status404NotFound,
|
||||
"Plan Not Found",
|
||||
$"Plan with ID '{planId}' was not found."));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(plan);
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<PlanRuleDto>, BadRequest<ProblemDetails>, Conflict<ProblemDetails>>> CreatePlan(
|
||||
[FromBody] CreatePlanRequest request,
|
||||
[FromServices] IPlanRuleStore store,
|
||||
[FromServices] PlanValidator validator,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate the request
|
||||
var validationRequest = new ValidatePlanRequest { Plan = request };
|
||||
var validationResult = validator.Validate(validationRequest);
|
||||
if (!validationResult.Valid)
|
||||
{
|
||||
return TypedResults.BadRequest(CreateProblemDetails(
|
||||
StatusCodes.Status400BadRequest,
|
||||
"Validation Failed",
|
||||
"The plan request is invalid.",
|
||||
validationResult.Errors));
|
||||
}
|
||||
|
||||
var actor = GetActorFromContext(context);
|
||||
|
||||
try
|
||||
{
|
||||
var created = await store.CreateAsync(request, actor, cancellationToken);
|
||||
return TypedResults.Created($"/api/admin/plans/{created.Id}", created);
|
||||
}
|
||||
catch (PlanNameConflictException ex)
|
||||
{
|
||||
return TypedResults.Conflict(CreateProblemDetails(
|
||||
StatusCodes.Status409Conflict,
|
||||
"Name Conflict",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PlanRuleDto>, BadRequest<ProblemDetails>, NotFound<ProblemDetails>, Conflict<ProblemDetails>>> UpdatePlan(
|
||||
string planId,
|
||||
[FromBody] UpdatePlanRequest request,
|
||||
[FromServices] IPlanRuleStore store,
|
||||
[FromServices] PlanValidator validator,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate the request if it includes full plan fields
|
||||
if (request.Repositories is not null || request.Name is not null)
|
||||
{
|
||||
var tempPlan = new CreatePlanRequest
|
||||
{
|
||||
Name = request.Name ?? "temp",
|
||||
Description = request.Description,
|
||||
Repositories = request.Repositories ?? [],
|
||||
Allowlist = request.Allowlist ?? [],
|
||||
RateLimit = request.RateLimit,
|
||||
};
|
||||
var validationRequest = new ValidatePlanRequest { Plan = tempPlan };
|
||||
var validationResult = validator.Validate(validationRequest);
|
||||
|
||||
// Filter out errors for fields not being updated
|
||||
var relevantErrors = validationResult.Errors
|
||||
.Where(e => request.Name is not null || !e.Field.StartsWith("plan.name", StringComparison.Ordinal))
|
||||
.Where(e => request.Repositories is not null || !e.Field.StartsWith("plan.repositories", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (relevantErrors.Count > 0)
|
||||
{
|
||||
return TypedResults.BadRequest(CreateProblemDetails(
|
||||
StatusCodes.Status400BadRequest,
|
||||
"Validation Failed",
|
||||
"The update request is invalid.",
|
||||
relevantErrors));
|
||||
}
|
||||
}
|
||||
|
||||
var actor = GetActorFromContext(context);
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.UpdateAsync(planId, request, actor, cancellationToken);
|
||||
return TypedResults.Ok(updated);
|
||||
}
|
||||
catch (PlanNotFoundException)
|
||||
{
|
||||
return TypedResults.NotFound(CreateProblemDetails(
|
||||
StatusCodes.Status404NotFound,
|
||||
"Plan Not Found",
|
||||
$"Plan with ID '{planId}' was not found."));
|
||||
}
|
||||
catch (PlanVersionConflictException ex)
|
||||
{
|
||||
return TypedResults.Conflict(CreateProblemDetails(
|
||||
StatusCodes.Status409Conflict,
|
||||
"Version Conflict",
|
||||
ex.Message));
|
||||
}
|
||||
catch (PlanNameConflictException ex)
|
||||
{
|
||||
return TypedResults.Conflict(CreateProblemDetails(
|
||||
StatusCodes.Status409Conflict,
|
||||
"Name Conflict",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound<ProblemDetails>>> DeletePlan(
|
||||
string planId,
|
||||
[FromServices] IPlanRuleStore store,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = GetActorFromContext(context);
|
||||
var deleted = await store.DeleteAsync(planId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return TypedResults.NotFound(CreateProblemDetails(
|
||||
StatusCodes.Status404NotFound,
|
||||
"Plan Not Found",
|
||||
$"Plan with ID '{planId}' was not found."));
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
private static Ok<ValidationResult> ValidatePlan(
|
||||
[FromBody] ValidatePlanRequest request,
|
||||
[FromServices] PlanValidator validator)
|
||||
{
|
||||
var result = validator.Validate(request);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Ok<PaginatedResponse<PlanAuditEntry>>> GetAuditHistory(
|
||||
[FromServices] IPlanRuleStore store,
|
||||
[FromQuery] string? planId = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Clamp page size
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
page = Math.Max(1, page);
|
||||
|
||||
var entries = await store.GetAuditHistoryAsync(planId, page, pageSize, cancellationToken);
|
||||
|
||||
var response = new PaginatedResponse<PlanAuditEntry>
|
||||
{
|
||||
Items = entries,
|
||||
TotalCount = entries.Count, // Note: in-memory store doesn't track total; real store should
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
private static string GetActorFromContext(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var sub = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
|
||||
var name = user.FindFirstValue(ClaimTypes.Name) ?? user.FindFirstValue("name");
|
||||
|
||||
return sub ?? name ?? "anonymous";
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblemDetails(
|
||||
int statusCode,
|
||||
string title,
|
||||
string detail,
|
||||
IReadOnlyList<ValidationError>? errors = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = statusCode,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
};
|
||||
|
||||
if (errors is not null && errors.Count > 0)
|
||||
{
|
||||
problem.Extensions["errors"] = errors;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user