Sprint SPRINT_20260415_003_DOCS_scheduler_registry_real_backend_cutover. - Scheduler WebService: Postgres-backed audit service + resolver job service, system schedule bootstrap, durable host tests, jwt app factory - PacksRegistry: persistence extensions + migration 002 runtime pack repo, durable runtime + startup contract tests - Registry.TokenService: Postgres plan rule store + admin endpoints, migration 001 initial schema, durable runtime + persistence tests - Scheduler.Plugin.Doctor: wiring for doctor job plugin - Sprint _019 (webhook rate limiter) and _002 (compose storage compat) land separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
11 KiB
C#
296 lines
11 KiB
C#
// <copyright file="PlanAdminEndpoints.cs" company="Stella Operations">
|
|
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
|
// </copyright>
|
|
|
|
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using System.Security.Claims;
|
|
using static StellaOps.Localization.T;
|
|
|
|
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,
|
|
_t("registry.error.plan_not_found"),
|
|
_t("registry.error.plan_not_found_detail", planId)));
|
|
}
|
|
|
|
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,
|
|
_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<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,
|
|
_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<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,
|
|
_t("registry.error.plan_not_found"),
|
|
_t("registry.error.plan_not_found_detail", planId)));
|
|
}
|
|
|
|
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 totalCount = await store.GetAuditHistoryCountAsync(planId, cancellationToken);
|
|
|
|
var response = new PaginatedResponse<PlanAuditEntry>
|
|
{
|
|
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<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;
|
|
}
|
|
}
|