Frontend gaps fill work. Testing fixes work. Auditing in progress.

This commit is contained in:
StellaOps Bot
2025-12-30 01:22:58 +02:00
parent 1dc4bcbf10
commit 7a5210e2aa
928 changed files with 183942 additions and 3941 deletions

View File

@@ -0,0 +1,401 @@
// <copyright file="AdminModels.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Plan rule definition for admin API.
/// </summary>
public sealed record PlanRuleDto
{
/// <summary>
/// Unique identifier for the plan.
/// </summary>
[Required]
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Display name for the plan.
/// </summary>
[Required]
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Whether the plan is currently active.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Repository scope rules.
/// </summary>
[JsonPropertyName("repositories")]
public IReadOnlyList<RepositoryRuleDto> Repositories { get; init; } = [];
/// <summary>
/// Explicit allowlist of client identifiers.
/// </summary>
[JsonPropertyName("allowlist")]
public IReadOnlyList<string> Allowlist { get; init; } = [];
/// <summary>
/// Optional rate limit configuration.
/// </summary>
[JsonPropertyName("rateLimit")]
public RateLimitDto? RateLimit { get; init; }
/// <summary>
/// UTC timestamp when this plan was created.
/// </summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// UTC timestamp when this plan was last modified.
/// </summary>
[JsonPropertyName("modifiedAt")]
public DateTimeOffset ModifiedAt { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; }
}
/// <summary>
/// Repository rule definition.
/// </summary>
public sealed record RepositoryRuleDto
{
/// <summary>
/// Repository pattern (supports wildcards: * matches any).
/// </summary>
[Required]
[JsonPropertyName("pattern")]
public required string Pattern { get; init; }
/// <summary>
/// Allowed actions for matching repositories.
/// </summary>
[JsonPropertyName("actions")]
public IReadOnlyList<string> Actions { get; init; } = ["pull"];
}
/// <summary>
/// Rate limit configuration.
/// </summary>
public sealed record RateLimitDto
{
/// <summary>
/// Maximum requests per window.
/// </summary>
[JsonPropertyName("maxRequests")]
public int MaxRequests { get; init; }
/// <summary>
/// Window duration in seconds.
/// </summary>
[JsonPropertyName("windowSeconds")]
public int WindowSeconds { get; init; }
}
/// <summary>
/// Request to create a new plan.
/// </summary>
public sealed record CreatePlanRequest
{
/// <summary>
/// Display name for the plan.
/// </summary>
[Required]
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Repository scope rules.
/// </summary>
[JsonPropertyName("repositories")]
public IReadOnlyList<RepositoryRuleDto> Repositories { get; init; } = [];
/// <summary>
/// Explicit allowlist of client identifiers.
/// </summary>
[JsonPropertyName("allowlist")]
public IReadOnlyList<string> Allowlist { get; init; } = [];
/// <summary>
/// Optional rate limit configuration.
/// </summary>
[JsonPropertyName("rateLimit")]
public RateLimitDto? RateLimit { get; init; }
}
/// <summary>
/// Request to update an existing plan.
/// </summary>
public sealed record UpdatePlanRequest
{
/// <summary>
/// Display name for the plan.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Whether the plan is currently active.
/// </summary>
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
/// <summary>
/// Repository scope rules.
/// </summary>
[JsonPropertyName("repositories")]
public IReadOnlyList<RepositoryRuleDto>? Repositories { get; init; }
/// <summary>
/// Explicit allowlist of client identifiers.
/// </summary>
[JsonPropertyName("allowlist")]
public IReadOnlyList<string>? Allowlist { get; init; }
/// <summary>
/// Optional rate limit configuration.
/// </summary>
[JsonPropertyName("rateLimit")]
public RateLimitDto? RateLimit { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[Required]
[JsonPropertyName("version")]
public required int Version { get; init; }
}
/// <summary>
/// Request for dry-run validation.
/// </summary>
public sealed record ValidatePlanRequest
{
/// <summary>
/// Plan to validate.
/// </summary>
[Required]
[JsonPropertyName("plan")]
public required CreatePlanRequest Plan { get; init; }
/// <summary>
/// Optional test scopes to evaluate.
/// </summary>
[JsonPropertyName("testScopes")]
public IReadOnlyList<TestScopeRequest>? TestScopes { get; init; }
}
/// <summary>
/// Test scope for dry-run validation.
/// </summary>
public sealed record TestScopeRequest
{
/// <summary>
/// Repository name to test.
/// </summary>
[Required]
[JsonPropertyName("repository")]
public required string Repository { get; init; }
/// <summary>
/// Actions to test.
/// </summary>
[JsonPropertyName("actions")]
public IReadOnlyList<string> Actions { get; init; } = ["pull"];
}
/// <summary>
/// Validation result from dry-run.
/// </summary>
public sealed record ValidationResult
{
/// <summary>
/// Whether the plan is valid.
/// </summary>
[JsonPropertyName("valid")]
public bool Valid { get; init; }
/// <summary>
/// Validation errors if any.
/// </summary>
[JsonPropertyName("errors")]
public IReadOnlyList<ValidationError> Errors { get; init; } = [];
/// <summary>
/// Warnings that don't prevent saving.
/// </summary>
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// Test scope results if provided.
/// </summary>
[JsonPropertyName("testResults")]
public IReadOnlyList<TestScopeResult>? TestResults { get; init; }
}
/// <summary>
/// Validation error detail.
/// </summary>
public sealed record ValidationError
{
/// <summary>
/// Field path that has the error.
/// </summary>
[JsonPropertyName("field")]
public required string Field { get; init; }
/// <summary>
/// Error message.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
}
/// <summary>
/// Test scope evaluation result.
/// </summary>
public sealed record TestScopeResult
{
/// <summary>
/// Repository that was tested.
/// </summary>
[JsonPropertyName("repository")]
public required string Repository { get; init; }
/// <summary>
/// Actions that were tested.
/// </summary>
[JsonPropertyName("actions")]
public IReadOnlyList<string> Actions { get; init; } = [];
/// <summary>
/// Whether access would be allowed.
/// </summary>
[JsonPropertyName("allowed")]
public bool Allowed { get; init; }
/// <summary>
/// Matching rule pattern if allowed.
/// </summary>
[JsonPropertyName("matchedPattern")]
public string? MatchedPattern { get; init; }
}
/// <summary>
/// Audit log entry for plan changes.
/// </summary>
public sealed record PlanAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Plan ID that was changed.
/// </summary>
[JsonPropertyName("planId")]
public required string PlanId { get; init; }
/// <summary>
/// Type of change: Created, Updated, Deleted, Enabled, Disabled.
/// </summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>
/// User or service that made the change.
/// </summary>
[JsonPropertyName("actor")]
public required string Actor { get; init; }
/// <summary>
/// UTC timestamp of the change.
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Summary of what changed.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Previous version if applicable.
/// </summary>
[JsonPropertyName("previousVersion")]
public int? PreviousVersion { get; init; }
/// <summary>
/// New version if applicable.
/// </summary>
[JsonPropertyName("newVersion")]
public int? NewVersion { get; init; }
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record PaginatedResponse<T>
{
/// <summary>
/// Items in this page.
/// </summary>
[JsonPropertyName("items")]
public IReadOnlyList<T> Items { get; init; } = [];
/// <summary>
/// Total count of items across all pages.
/// </summary>
[JsonPropertyName("totalCount")]
public int TotalCount { get; init; }
/// <summary>
/// Current page number (1-based).
/// </summary>
[JsonPropertyName("page")]
public int Page { get; init; }
/// <summary>
/// Page size.
/// </summary>
[JsonPropertyName("pageSize")]
public int PageSize { get; init; }
}

View File

@@ -0,0 +1,100 @@
// <copyright file="IPlanRuleStore.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Abstraction for persistent plan rule storage.
/// </summary>
public interface IPlanRuleStore
{
/// <summary>
/// Gets all plan rules ordered by name.
/// </summary>
Task<IReadOnlyList<PlanRuleDto>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets a plan rule by ID.
/// </summary>
Task<PlanRuleDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a plan rule by name.
/// </summary>
Task<PlanRuleDto?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new plan rule.
/// </summary>
Task<PlanRuleDto> CreateAsync(CreatePlanRequest request, string actor, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing plan rule.
/// </summary>
Task<PlanRuleDto> UpdateAsync(string id, UpdatePlanRequest request, string actor, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a plan rule.
/// </summary>
Task<bool> DeleteAsync(string id, string actor, CancellationToken cancellationToken = default);
/// <summary>
/// Gets audit history for a plan.
/// </summary>
Task<IReadOnlyList<PlanAuditEntry>> GetAuditHistoryAsync(
string? planId = null,
int page = 1,
int pageSize = 50,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Exception thrown when a plan rule version conflict occurs.
/// </summary>
public sealed class PlanVersionConflictException : System.Exception
{
public PlanVersionConflictException(string planId, int expectedVersion, int actualVersion)
: base($"Plan '{planId}' version conflict: expected {expectedVersion}, actual {actualVersion}")
{
PlanId = planId;
ExpectedVersion = expectedVersion;
ActualVersion = actualVersion;
}
public string PlanId { get; }
public int ExpectedVersion { get; }
public int ActualVersion { get; }
}
/// <summary>
/// Exception thrown when a plan rule is not found.
/// </summary>
public sealed class PlanNotFoundException : System.Exception
{
public PlanNotFoundException(string planId)
: base($"Plan '{planId}' not found")
{
PlanId = planId;
}
public string PlanId { get; }
}
/// <summary>
/// Exception thrown when a plan name already exists.
/// </summary>
public sealed class PlanNameConflictException : System.Exception
{
public PlanNameConflictException(string name)
: base($"Plan with name '{name}' already exists")
{
Name = name;
}
public string Name { get; }
}

View File

@@ -0,0 +1,226 @@
// <copyright file="InMemoryPlanRuleStore.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// In-memory implementation of plan rule storage for development and testing.
/// Production deployments should use a persistent store (PostgreSQL).
/// </summary>
public sealed class InMemoryPlanRuleStore : IPlanRuleStore
{
private readonly ConcurrentDictionary<string, PlanRuleDto> _plans = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentBag<PlanAuditEntry> _auditLog = [];
private readonly TimeProvider _timeProvider;
private int _auditIdCounter;
public InMemoryPlanRuleStore(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<IReadOnlyList<PlanRuleDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
var plans = _plans.Values
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<PlanRuleDto>>(plans);
}
public Task<PlanRuleDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
{
_plans.TryGetValue(id, out var plan);
return Task.FromResult(plan);
}
public Task<PlanRuleDto?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{
var plan = _plans.Values.FirstOrDefault(p =>
string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(plan);
}
public Task<PlanRuleDto> CreateAsync(CreatePlanRequest request, string actor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
// Check for name conflict
var existing = _plans.Values.FirstOrDefault(p =>
string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
throw new PlanNameConflictException(request.Name);
}
var now = _timeProvider.GetUtcNow();
var id = GenerateId();
var plan = new PlanRuleDto
{
Id = id,
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
Enabled = true,
Repositories = request.Repositories,
Allowlist = request.Allowlist,
RateLimit = request.RateLimit,
CreatedAt = now,
ModifiedAt = now,
Version = 1,
};
if (!_plans.TryAdd(id, plan))
{
throw new InvalidOperationException("Failed to create plan due to ID collision.");
}
AddAuditEntry(id, "Created", actor, $"Created plan '{plan.Name}'", null, 1);
return Task.FromResult(plan);
}
public Task<PlanRuleDto> UpdateAsync(string id, UpdatePlanRequest request, string actor, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
if (!_plans.TryGetValue(id, out var existing))
{
throw new PlanNotFoundException(id);
}
if (existing.Version != request.Version)
{
throw new PlanVersionConflictException(id, request.Version, existing.Version);
}
// Check for name conflict if name is changing
if (request.Name is not null &&
!string.Equals(request.Name, existing.Name, StringComparison.OrdinalIgnoreCase))
{
var nameConflict = _plans.Values.FirstOrDefault(p =>
!string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase));
if (nameConflict is not null)
{
throw new PlanNameConflictException(request.Name);
}
}
var now = _timeProvider.GetUtcNow();
var changes = new List<string>();
var updated = existing with
{
Name = request.Name?.Trim() ?? existing.Name,
Description = request.Description?.Trim() ?? existing.Description,
Enabled = request.Enabled ?? existing.Enabled,
Repositories = request.Repositories ?? existing.Repositories,
Allowlist = request.Allowlist ?? existing.Allowlist,
RateLimit = request.RateLimit ?? existing.RateLimit,
ModifiedAt = now,
Version = existing.Version + 1,
};
if (request.Name is not null && request.Name != existing.Name)
{
changes.Add($"name: '{existing.Name}' → '{updated.Name}'");
}
if (request.Enabled.HasValue && request.Enabled.Value != existing.Enabled)
{
changes.Add($"enabled: {existing.Enabled} → {updated.Enabled}");
}
if (request.Repositories is not null)
{
changes.Add("repositories updated");
}
if (request.Allowlist is not null)
{
changes.Add("allowlist updated");
}
if (!_plans.TryUpdate(id, updated, existing))
{
throw new PlanVersionConflictException(id, request.Version, existing.Version);
}
var summary = changes.Count > 0
? $"Updated: {string.Join(", ", changes)}"
: "No changes";
AddAuditEntry(id, "Updated", actor, summary, existing.Version, updated.Version);
return Task.FromResult(updated);
}
public Task<bool> DeleteAsync(string id, string actor, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
if (_plans.TryRemove(id, out var removed))
{
AddAuditEntry(id, "Deleted", actor, $"Deleted plan '{removed.Name}'", removed.Version, null);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<IReadOnlyList<PlanAuditEntry>> GetAuditHistoryAsync(
string? planId = null,
int page = 1,
int pageSize = 50,
CancellationToken cancellationToken = default)
{
var query = _auditLog.AsEnumerable();
if (!string.IsNullOrWhiteSpace(planId))
{
query = query.Where(e => string.Equals(e.PlanId, planId, StringComparison.OrdinalIgnoreCase));
}
var result = query
.OrderByDescending(e => e.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Task.FromResult<IReadOnlyList<PlanAuditEntry>>(result);
}
private void AddAuditEntry(string planId, string action, string actor, string summary, int? previousVersion, int? newVersion)
{
var auditId = Interlocked.Increment(ref _auditIdCounter);
var entry = new PlanAuditEntry
{
Id = $"audit-{auditId:D8}",
PlanId = planId,
Action = action,
Actor = actor,
Timestamp = _timeProvider.GetUtcNow(),
Summary = summary,
PreviousVersion = previousVersion,
NewVersion = newVersion,
};
_auditLog.Add(entry);
}
private static string GenerateId()
{
return $"plan-{Guid.NewGuid():N}"[..16];
}
}

View File

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

View File

@@ -0,0 +1,237 @@
// <copyright file="PlanValidator.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Registry.TokenService.Admin;
/// <summary>
/// Validates plan rules and performs dry-run scope evaluation.
/// </summary>
public sealed class PlanValidator
{
private static readonly HashSet<string> ValidActions = new(StringComparer.OrdinalIgnoreCase)
{
"pull", "push", "delete", "*"
};
/// <summary>
/// Validates a plan request and optionally evaluates test scopes.
/// </summary>
public ValidationResult Validate(ValidatePlanRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<ValidationError>();
var warnings = new List<string>();
// Validate name
if (string.IsNullOrWhiteSpace(request.Plan.Name))
{
errors.Add(new ValidationError { Field = "plan.name", Message = "Plan name is required." });
}
else if (request.Plan.Name.Length > 128)
{
errors.Add(new ValidationError { Field = "plan.name", Message = "Plan name must be 128 characters or less." });
}
// Validate repositories
if (request.Plan.Repositories.Count == 0)
{
warnings.Add("Plan has no repository rules; all repository access will be denied.");
}
for (int i = 0; i < request.Plan.Repositories.Count; i++)
{
var repo = request.Plan.Repositories[i];
var fieldPrefix = $"plan.repositories[{i}]";
if (string.IsNullOrWhiteSpace(repo.Pattern))
{
errors.Add(new ValidationError { Field = $"{fieldPrefix}.pattern", Message = "Repository pattern is required." });
}
else
{
// Validate pattern syntax
var patternError = ValidatePattern(repo.Pattern);
if (patternError is not null)
{
errors.Add(new ValidationError { Field = $"{fieldPrefix}.pattern", Message = patternError });
}
}
if (repo.Actions.Count == 0)
{
errors.Add(new ValidationError { Field = $"{fieldPrefix}.actions", Message = "At least one action is required." });
}
else
{
foreach (var action in repo.Actions)
{
if (!ValidActions.Contains(action))
{
errors.Add(new ValidationError
{
Field = $"{fieldPrefix}.actions",
Message = $"Invalid action '{action}'. Valid actions are: pull, push, delete, *."
});
}
}
}
}
// Validate rate limit
if (request.Plan.RateLimit is not null)
{
if (request.Plan.RateLimit.MaxRequests <= 0)
{
errors.Add(new ValidationError { Field = "plan.rateLimit.maxRequests", Message = "Max requests must be positive." });
}
if (request.Plan.RateLimit.WindowSeconds <= 0)
{
errors.Add(new ValidationError { Field = "plan.rateLimit.windowSeconds", Message = "Window seconds must be positive." });
}
}
// Validate allowlist
for (int i = 0; i < request.Plan.Allowlist.Count; i++)
{
if (string.IsNullOrWhiteSpace(request.Plan.Allowlist[i]))
{
errors.Add(new ValidationError { Field = $"plan.allowlist[{i}]", Message = "Allowlist entry cannot be empty." });
}
}
// Check for overlapping patterns (warning only)
var overlaps = FindOverlappingPatterns(request.Plan.Repositories);
foreach (var overlap in overlaps)
{
warnings.Add($"Repository patterns may overlap: '{overlap.Item1}' and '{overlap.Item2}'");
}
// Evaluate test scopes if provided
List<TestScopeResult>? testResults = null;
if (request.TestScopes is not null && request.TestScopes.Count > 0 && errors.Count == 0)
{
testResults = EvaluateTestScopes(request.Plan.Repositories, request.TestScopes);
}
return new ValidationResult
{
Valid = errors.Count == 0,
Errors = errors,
Warnings = warnings,
TestResults = testResults,
};
}
private static string? ValidatePattern(string pattern)
{
if (pattern.Contains("**", StringComparison.Ordinal))
{
return "Double wildcards (**) are not supported; use single wildcard (*).";
}
// Check for invalid characters
if (pattern.Any(c => char.IsControl(c)))
{
return "Pattern contains invalid control characters.";
}
// Try to compile the regex
try
{
var escaped = Regex.Escape(pattern);
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
_ = new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return null;
}
catch (ArgumentException ex)
{
return $"Invalid pattern: {ex.Message}";
}
}
private static List<(string, string)> FindOverlappingPatterns(IReadOnlyList<RepositoryRuleDto> repositories)
{
var overlaps = new List<(string, string)>();
for (int i = 0; i < repositories.Count; i++)
{
for (int j = i + 1; j < repositories.Count; j++)
{
var p1 = repositories[i].Pattern;
var p2 = repositories[j].Pattern;
// Simple overlap detection: if one pattern is a prefix of another
if (p1.StartsWith(p2.Replace("*", "", StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase) ||
p2.StartsWith(p1.Replace("*", "", StringComparison.Ordinal), StringComparison.OrdinalIgnoreCase))
{
overlaps.Add((p1, p2));
}
}
}
return overlaps;
}
private static List<TestScopeResult> EvaluateTestScopes(
IReadOnlyList<RepositoryRuleDto> repositories,
IReadOnlyList<TestScopeRequest> testScopes)
{
var results = new List<TestScopeResult>();
foreach (var test in testScopes)
{
var (allowed, matchedPattern) = EvaluateScope(repositories, test.Repository, test.Actions);
results.Add(new TestScopeResult
{
Repository = test.Repository,
Actions = test.Actions,
Allowed = allowed,
MatchedPattern = matchedPattern,
});
}
return results;
}
private static (bool Allowed, string? MatchedPattern) EvaluateScope(
IReadOnlyList<RepositoryRuleDto> repositories,
string repository,
IReadOnlyList<string> actions)
{
foreach (var rule in repositories)
{
var pattern = CompilePattern(rule.Pattern);
if (!pattern.IsMatch(repository))
{
continue;
}
// Check if all actions are allowed
var ruleActions = new HashSet<string>(rule.Actions, StringComparer.OrdinalIgnoreCase);
var allAllowed = ruleActions.Contains("*") ||
actions.All(a => ruleActions.Contains(a));
if (allAllowed)
{
return (true, rule.Pattern);
}
}
return (false, null);
}
private static Regex CompilePattern(string pattern)
{
var escaped = Regex.Escape(pattern);
escaped = escaped.Replace(@"\*", ".*", StringComparison.Ordinal);
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
}

View File

@@ -16,6 +16,7 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Telemetry.Core;
using StellaOps.Registry.TokenService;
using StellaOps.Registry.TokenService.Admin;
using StellaOps.Registry.TokenService.Observability;
var builder = WebApplication.CreateBuilder(args);
@@ -57,6 +58,10 @@ builder.Services.AddSingleton<PlanRegistry>(sp =>
});
builder.Services.AddSingleton<RegistryTokenIssuer>();
// Plan Admin API dependencies
builder.Services.AddSingleton<IPlanRuleStore, InMemoryPlanRuleStore>();
builder.Services.AddSingleton<PlanValidator>();
builder.Services.AddHealthChecks().AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
@@ -102,6 +107,14 @@ builder.Services.AddAuthorization(options =>
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
// Admin policy for plan management
options.AddPolicy("registry.admin", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new StellaOpsScopeRequirement(["registry.admin"]));
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
});
});
var app = builder.Build();
@@ -112,6 +125,9 @@ app.UseAuthorization();
app.MapHealthChecks("/healthz");
// Plan Admin API endpoints
app.MapPlanAdminEndpoints();
app.MapGet("/token", (
HttpContext context,
[FromServices] IOptions<RegistryTokenServiceOptions> options,