Files
git.stella-ops.org/src/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs
master 302826aedb feat(scheduler,packsregistry,registry): postgres backend cutover
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>
2026-04-19 14:36:05 +03:00

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;
}
}