// // Copyright (c) Stella Operations. Licensed under BUSL-1.1. // using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; using static StellaOps.Localization.T; namespace StellaOps.Registry.TokenService.Admin; /// /// Minimal API endpoint mappings for Plan Rule administration. /// public static class PlanAdminEndpoints { /// /// Maps the plan admin endpoints to the application. /// 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>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden); group.MapGet("/{planId}", GetPlan) .WithName("GetPlan") .WithDescription("Gets a plan rule by ID.") .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden); group.MapPost("/", CreatePlan) .WithName("CreatePlan") .WithDescription("Creates a new plan rule.") .Produces(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(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(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>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden); } private static async Task>> ListPlans( [FromServices] IPlanRuleStore store, CancellationToken cancellationToken) { var plans = await store.GetAllAsync(cancellationToken); return TypedResults.Ok(plans); } private static async Task, NotFound>> 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, _t("registry.error.plan_not_found"), _t("registry.error.plan_not_found_detail", planId))); } return TypedResults.Ok(plan); } private static async Task, BadRequest, Conflict>> 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, _t("registry.error.validation_failed"), _t("registry.validation.plan_request_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, _t("registry.error.plan_name_conflict"), ex.Message)); } } private static async Task, BadRequest, NotFound, Conflict>> 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, _t("registry.error.validation_failed"), _t("registry.validation.plan_update_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, _t("registry.error.plan_not_found"), _t("registry.error.plan_not_found_detail", planId))); } catch (PlanVersionConflictException ex) { return TypedResults.Conflict(CreateProblemDetails( StatusCodes.Status409Conflict, _t("registry.error.plan_version_conflict"), ex.Message)); } catch (PlanNameConflictException ex) { return TypedResults.Conflict(CreateProblemDetails( StatusCodes.Status409Conflict, _t("registry.error.plan_name_conflict"), ex.Message)); } } private static async Task>> 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, _t("registry.error.plan_not_found"), _t("registry.error.plan_not_found_detail", planId))); } return TypedResults.NoContent(); } private static Ok ValidatePlan( [FromBody] ValidatePlanRequest request, [FromServices] PlanValidator validator) { var result = validator.Validate(request); return TypedResults.Ok(result); } private static async Task>> 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 totalCount = await store.GetAuditHistoryCountAsync(planId, cancellationToken); var response = new PaginatedResponse { Items = entries, TotalCount = totalCount, 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? 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; } }