Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// <copyright file="InMemoryPlanRuleStoreTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Tests.Admin;
|
||||
|
||||
public sealed class InMemoryPlanRuleStoreTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryPlanRuleStore _store;
|
||||
|
||||
public InMemoryPlanRuleStoreTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
_store = new InMemoryPlanRuleStore(_timeProvider);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_CreatesNewPlan()
|
||||
{
|
||||
var request = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Description = "A test plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
var result = await _store.CreateAsync(request, "test-user");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Id);
|
||||
Assert.Equal("test-plan", result.Name);
|
||||
Assert.Equal("A test plan", result.Description);
|
||||
Assert.True(result.Enabled);
|
||||
Assert.Single(result.Repositories);
|
||||
Assert.Equal(1, result.Version);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.CreatedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.ModifiedAt);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_DuplicateName_ThrowsConflict()
|
||||
{
|
||||
var request = new CreatePlanRequest
|
||||
{
|
||||
Name = "duplicate-plan",
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
await _store.CreateAsync(request, "test-user");
|
||||
|
||||
await Assert.ThrowsAsync<PlanNameConflictException>(
|
||||
() => _store.CreateAsync(request, "test-user"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsPlansOrderedByName()
|
||||
{
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = "zebra" }, "user");
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = "alpha" }, "user");
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = "beta" }, "user");
|
||||
|
||||
var plans = await _store.GetAllAsync();
|
||||
|
||||
Assert.Equal(3, plans.Count);
|
||||
Assert.Equal("alpha", plans[0].Name);
|
||||
Assert.Equal("beta", plans[1].Name);
|
||||
Assert.Equal("zebra", plans[2].Name);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ExistingId_ReturnsPlan()
|
||||
{
|
||||
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
|
||||
|
||||
var result = await _store.GetByIdAsync(created.Id);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(created.Id, result.Id);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistingId_ReturnsNull()
|
||||
{
|
||||
var result = await _store.GetByIdAsync("non-existing-id");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByNameAsync_ExistingName_ReturnsPlan()
|
||||
{
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = "test-plan" }, "user");
|
||||
|
||||
var result = await _store.GetByNameAsync("test-plan");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("test-plan", result.Name);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByNameAsync_CaseInsensitive()
|
||||
{
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = "Test-Plan" }, "user");
|
||||
|
||||
var result = await _store.GetByNameAsync("TEST-PLAN");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Test-Plan", result.Name);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UpdatesFields()
|
||||
{
|
||||
var created = await _store.CreateAsync(
|
||||
new CreatePlanRequest { Name = "original", Description = "Original desc" },
|
||||
"user");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Name = "updated",
|
||||
Description = "Updated desc",
|
||||
Version = 1,
|
||||
};
|
||||
|
||||
var updated = await _store.UpdateAsync(created.Id, updateRequest, "user");
|
||||
|
||||
Assert.Equal("updated", updated.Name);
|
||||
Assert.Equal("Updated desc", updated.Description);
|
||||
Assert.Equal(2, updated.Version);
|
||||
Assert.Equal(created.CreatedAt, updated.CreatedAt);
|
||||
Assert.True(updated.ModifiedAt > created.ModifiedAt);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_VersionMismatch_ThrowsConflict()
|
||||
{
|
||||
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Name = "updated",
|
||||
Version = 999, // Wrong version
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<PlanVersionConflictException>(
|
||||
() => _store.UpdateAsync(created.Id, updateRequest, "user"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_NonExistingId_ThrowsNotFound()
|
||||
{
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Name = "updated",
|
||||
Version = 1,
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<PlanNotFoundException>(
|
||||
() => _store.UpdateAsync("non-existing", updateRequest, "user"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_DuplicateName_ThrowsConflict()
|
||||
{
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = "plan-a" }, "user");
|
||||
var planB = await _store.CreateAsync(new CreatePlanRequest { Name = "plan-b" }, "user");
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Name = "plan-a", // Trying to rename to existing name
|
||||
Version = 1,
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<PlanNameConflictException>(
|
||||
() => _store.UpdateAsync(planB.Id, updateRequest, "user"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_SameName_CaseInsensitive_DoesNotConflict()
|
||||
{
|
||||
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "Test-Plan" }, "user");
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Name = "TEST-PLAN", // Same name, different case
|
||||
Version = 1,
|
||||
};
|
||||
|
||||
var updated = await _store.UpdateAsync(created.Id, updateRequest, "user");
|
||||
|
||||
Assert.Equal("TEST-PLAN", updated.Name);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PartialUpdate_PreservesUnchangedFields()
|
||||
{
|
||||
var created = await _store.CreateAsync(
|
||||
new CreatePlanRequest
|
||||
{
|
||||
Name = "test",
|
||||
Description = "Original",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto { Pattern = "org/*", Actions = ["pull"] }
|
||||
],
|
||||
},
|
||||
"user");
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Description = "Updated",
|
||||
Version = 1,
|
||||
};
|
||||
|
||||
var updated = await _store.UpdateAsync(created.Id, updateRequest, "user");
|
||||
|
||||
Assert.Equal("test", updated.Name);
|
||||
Assert.Equal("Updated", updated.Description);
|
||||
Assert.Single(updated.Repositories);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingId_ReturnsTrue()
|
||||
{
|
||||
var created = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
|
||||
|
||||
var result = await _store.DeleteAsync(created.Id, "user");
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var fetched = await _store.GetByIdAsync(created.Id);
|
||||
Assert.Null(fetched);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistingId_ReturnsFalse()
|
||||
{
|
||||
var result = await _store.DeleteAsync("non-existing", "user");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAuditHistoryAsync_ReturnsEntriesOrderedByTimestampDesc()
|
||||
{
|
||||
var plan1 = await _store.CreateAsync(new CreatePlanRequest { Name = "plan1" }, "user1");
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
await _store.UpdateAsync(plan1.Id, new UpdatePlanRequest { Description = "Updated", Version = 1 }, "user2");
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
await _store.DeleteAsync(plan1.Id, "user3");
|
||||
|
||||
var history = await _store.GetAuditHistoryAsync();
|
||||
|
||||
Assert.Equal(3, history.Count);
|
||||
Assert.Equal("Deleted", history[0].Action);
|
||||
Assert.Equal("user3", history[0].Actor);
|
||||
Assert.Equal("Updated", history[1].Action);
|
||||
Assert.Equal("user2", history[1].Actor);
|
||||
Assert.Equal("Created", history[2].Action);
|
||||
Assert.Equal("user1", history[2].Actor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAuditHistoryAsync_FilterByPlanId()
|
||||
{
|
||||
var plan1 = await _store.CreateAsync(new CreatePlanRequest { Name = "plan1" }, "user");
|
||||
var plan2 = await _store.CreateAsync(new CreatePlanRequest { Name = "plan2" }, "user");
|
||||
|
||||
await _store.UpdateAsync(plan1.Id, new UpdatePlanRequest { Description = "Updated", Version = 1 }, "user");
|
||||
|
||||
var history = await _store.GetAuditHistoryAsync(planId: plan1.Id);
|
||||
|
||||
Assert.Equal(2, history.Count);
|
||||
Assert.All(history, e => Assert.Equal(plan1.Id, e.PlanId));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAuditHistoryAsync_Pagination()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _store.CreateAsync(new CreatePlanRequest { Name = $"plan{i}" }, "user");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
var page1 = await _store.GetAuditHistoryAsync(page: 1, pageSize: 3);
|
||||
var page2 = await _store.GetAuditHistoryAsync(page: 2, pageSize: 3);
|
||||
|
||||
Assert.Equal(3, page1.Count);
|
||||
Assert.Equal(3, page2.Count);
|
||||
|
||||
// Verify different entries
|
||||
Assert.DoesNotContain(page1, e => page2.Any(p2 => p2.Id == e.Id));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AuditEntry_IncludesVersionInfo()
|
||||
{
|
||||
var plan = await _store.CreateAsync(new CreatePlanRequest { Name = "test" }, "user");
|
||||
|
||||
await _store.UpdateAsync(plan.Id, new UpdatePlanRequest { Description = "v2", Version = 1 }, "user");
|
||||
await _store.UpdateAsync(plan.Id, new UpdatePlanRequest { Description = "v3", Version = 2 }, "user");
|
||||
|
||||
var history = await _store.GetAuditHistoryAsync(planId: plan.Id);
|
||||
|
||||
var createEntry = history.Single(e => e.Action == "Created");
|
||||
Assert.Null(createEntry.PreviousVersion);
|
||||
Assert.Equal(1, createEntry.NewVersion);
|
||||
|
||||
var updateEntries = history.Where(e => e.Action == "Updated").OrderBy(e => e.Timestamp).ToList();
|
||||
Assert.Equal(1, updateEntries[0].PreviousVersion);
|
||||
Assert.Equal(2, updateEntries[0].NewVersion);
|
||||
Assert.Equal(2, updateEntries[1].PreviousVersion);
|
||||
Assert.Equal(3, updateEntries[1].NewVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// <copyright file="PlanAdminEndpointsTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Tests.Admin;
|
||||
|
||||
public sealed class PlanAdminEndpointsTests : IClassFixture<PlanAdminEndpointsTests.TestWebApplicationFactory>
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
||||
public PlanAdminEndpointsTests(TestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ListPlans_ReturnsEmptyList_WhenNoPlans()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.GetAsync("/api/admin/plans/");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var plans = await response.Content.ReadFromJsonAsync<List<PlanRuleDto>>();
|
||||
Assert.NotNull(plans);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreatePlan_ReturnsCreated()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var request = new CreatePlanRequest
|
||||
{
|
||||
Name = $"test-plan-{Guid.NewGuid():N}",
|
||||
Description = "A test plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/admin/plans/", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(request.Name, created.Name);
|
||||
Assert.Equal(1, created.Version);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreatePlan_DuplicateName_ReturnsConflict()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var planName = $"duplicate-{Guid.NewGuid():N}";
|
||||
|
||||
var request = new CreatePlanRequest
|
||||
{
|
||||
Name = planName,
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
var response1 = await client.PostAsJsonAsync("/api/admin/plans/", request);
|
||||
Assert.Equal(HttpStatusCode.Created, response1.StatusCode);
|
||||
|
||||
var response2 = await client.PostAsJsonAsync("/api/admin/plans/", request);
|
||||
Assert.Equal(HttpStatusCode.Conflict, response2.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreatePlan_InvalidRequest_ReturnsBadRequest()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var request = new CreatePlanRequest
|
||||
{
|
||||
Name = "", // Empty name
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/admin/plans/", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetPlan_ExistingPlan_ReturnsPlan()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var createRequest = new CreatePlanRequest
|
||||
{
|
||||
Name = $"get-test-{Guid.NewGuid():N}",
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/admin/plans/{created!.Id}");
|
||||
getResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var fetched = await getResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(created.Id, fetched.Id);
|
||||
Assert.Equal(created.Name, fetched.Name);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetPlan_NonExisting_ReturnsNotFound()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.GetAsync("/api/admin/plans/non-existing-id");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task UpdatePlan_UpdatesFields()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var createRequest = new CreatePlanRequest
|
||||
{
|
||||
Name = $"update-test-{Guid.NewGuid():N}",
|
||||
Description = "Original",
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Description = "Updated",
|
||||
Version = 1,
|
||||
};
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync($"/api/admin/plans/{created!.Id}", updateRequest);
|
||||
updateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Updated", updated.Description);
|
||||
Assert.Equal(2, updated.Version);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task UpdatePlan_VersionMismatch_ReturnsConflict()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var createRequest = new CreatePlanRequest
|
||||
{
|
||||
Name = $"version-test-{Guid.NewGuid():N}",
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
|
||||
var updateRequest = new UpdatePlanRequest
|
||||
{
|
||||
Description = "Updated",
|
||||
Version = 999, // Wrong version
|
||||
};
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync($"/api/admin/plans/{created!.Id}", updateRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, updateResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task DeletePlan_ExistingPlan_ReturnsNoContent()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var createRequest = new CreatePlanRequest
|
||||
{
|
||||
Name = $"delete-test-{Guid.NewGuid():N}",
|
||||
Repositories = [],
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<PlanRuleDto>();
|
||||
|
||||
var deleteResponse = await client.DeleteAsync($"/api/admin/plans/{created!.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/admin/plans/{created.Id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task DeletePlan_NonExisting_ReturnsNotFound()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.DeleteAsync("/api/admin/plans/non-existing-id");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ValidatePlan_ValidPlan_ReturnsValid()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/admin/plans/validate", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ValidationResult>();
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Valid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ValidatePlan_InvalidPlan_ReturnsInvalid()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "",
|
||||
Repositories = [],
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/admin/plans/validate", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ValidationResult>();
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.Valid);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetAuditHistory_ReturnsEntries()
|
||||
{
|
||||
var client = _factory.CreateAuthenticatedClient();
|
||||
|
||||
// Create a plan to generate audit entries
|
||||
var createRequest = new CreatePlanRequest
|
||||
{
|
||||
Name = $"audit-test-{Guid.NewGuid():N}",
|
||||
Repositories = [],
|
||||
};
|
||||
await client.PostAsJsonAsync("/api/admin/plans/", createRequest);
|
||||
|
||||
var response = await client.GetAsync("/api/admin/plans/audit");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<PaginatedResponse<PlanAuditEntry>>();
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result.Items);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Endpoints_Unauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/admin/plans/");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// Replace TimeProvider with fake for deterministic tests
|
||||
services.AddSingleton<TimeProvider>(new FakeTimeProvider(
|
||||
new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
// Add test authentication
|
||||
services.AddAuthentication("Test")
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
|
||||
|
||||
// Configure authorization to use test scheme
|
||||
services.PostConfigure<Microsoft.AspNetCore.Authorization.AuthorizationOptions>(options =>
|
||||
{
|
||||
foreach (var policyName in new[] { "registry.admin", "registry.token.issue" })
|
||||
{
|
||||
var existingPolicy = options.GetPolicy(policyName);
|
||||
if (existingPolicy != null)
|
||||
{
|
||||
var newPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddAuthenticationSchemes("Test")
|
||||
.Build();
|
||||
options.AddPolicy(policyName, newPolicy);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public HttpClient CreateAuthenticatedClient()
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Test");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder) : base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.Authorization.Any())
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "test-user"),
|
||||
new Claim(ClaimTypes.Name, "Test User"),
|
||||
new Claim("scope", "registry.admin"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, "Test");
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// <copyright file="PlanValidatorTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Tests.Admin;
|
||||
|
||||
public sealed class PlanValidatorTests
|
||||
{
|
||||
private readonly PlanValidator _validator = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ValidPlan_ReturnsValid()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Description = "A test plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull", "push"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_EmptyName_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Field == "plan.name");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_NameTooLong_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = new string('x', 200),
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Field == "plan.name" && e.Message.Contains("128"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_NoRepositories_ReturnsWarning()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "empty-plan",
|
||||
Repositories = [],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("no repository rules"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_EmptyPattern_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Field == "plan.repositories[0].pattern");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_NoActions_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = []
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Field == "plan.repositories[0].actions");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_InvalidAction_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull", "invalid-action"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Message.Contains("invalid-action"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_DoubleWildcard_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/**",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Message.Contains("Double wildcards"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_InvalidRateLimit_ReturnsError()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
RateLimit = new RateLimitDto
|
||||
{
|
||||
MaxRequests = 0,
|
||||
WindowSeconds = -1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.Contains(result.Errors, e => e.Field == "plan.rateLimit.maxRequests");
|
||||
Assert.Contains(result.Errors, e => e.Field == "plan.rateLimit.windowSeconds");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_WithTestScopes_EvaluatesCorrectly()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/public/*",
|
||||
Actions = ["pull"]
|
||||
},
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/private/*",
|
||||
Actions = ["pull", "push"]
|
||||
}
|
||||
],
|
||||
},
|
||||
TestScopes =
|
||||
[
|
||||
new TestScopeRequest
|
||||
{
|
||||
Repository = "org/public/app",
|
||||
Actions = ["pull"]
|
||||
},
|
||||
new TestScopeRequest
|
||||
{
|
||||
Repository = "org/private/app",
|
||||
Actions = ["push"]
|
||||
},
|
||||
new TestScopeRequest
|
||||
{
|
||||
Repository = "other/repo",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.NotNull(result.TestResults);
|
||||
Assert.Equal(3, result.TestResults.Count);
|
||||
|
||||
Assert.True(result.TestResults[0].Allowed);
|
||||
Assert.Equal("org/public/*", result.TestResults[0].MatchedPattern);
|
||||
|
||||
Assert.True(result.TestResults[1].Allowed);
|
||||
Assert.Equal("org/private/*", result.TestResults[1].MatchedPattern);
|
||||
|
||||
Assert.False(result.TestResults[2].Allowed);
|
||||
Assert.Null(result.TestResults[2].MatchedPattern);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_WildcardAction_AllowsAllActions()
|
||||
{
|
||||
var request = new ValidatePlanRequest
|
||||
{
|
||||
Plan = new CreatePlanRequest
|
||||
{
|
||||
Name = "test-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["*"]
|
||||
}
|
||||
],
|
||||
},
|
||||
TestScopes =
|
||||
[
|
||||
new TestScopeRequest
|
||||
{
|
||||
Repository = "org/app",
|
||||
Actions = ["pull", "push", "delete"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _validator.Validate(request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.NotNull(result.TestResults);
|
||||
Assert.Single(result.TestResults);
|
||||
Assert.True(result.TestResults[0].Allowed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user