feat(scheduler,packsregistry,registry): postgres backend cutover
Sprint SPRINT_20260415_003_DOCS_scheduler_registry_real_backend_cutover. - Scheduler WebService: Postgres-backed audit service + resolver job service, system schedule bootstrap, durable host tests, jwt app factory - PacksRegistry: persistence extensions + migration 002 runtime pack repo, durable runtime + startup contract tests - Registry.TokenService: Postgres plan rule store + admin endpoints, migration 001 initial schema, durable runtime + persistence tests - Scheduler.Plugin.Doctor: wiring for doctor job plugin - Sprint _019 (webhook rate limiter) and _002 (compose storage compat) land separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,11 @@ public interface IPlanRuleStore
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of audit history entries for the specified plan.
|
||||
/// </summary>
|
||||
Task<int> GetAuditHistoryCountAsync(string? planId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -207,6 +207,18 @@ public sealed class InMemoryPlanRuleStore : IPlanRuleStore
|
||||
return Task.FromResult<IReadOnlyList<PlanAuditEntry>>(result);
|
||||
}
|
||||
|
||||
public Task<int> GetAuditHistoryCountAsync(string? planId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _auditLog.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(planId))
|
||||
{
|
||||
query = query.Where(e => string.Equals(e.PlanId, planId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Task.FromResult(query.Count());
|
||||
}
|
||||
|
||||
private void AddAuditEntry(string planId, string action, string actor, string summary, int? previousVersion, int? newVersion)
|
||||
{
|
||||
var auditId = Interlocked.Increment(ref _auditIdCounter);
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// <copyright file="PostgresPlanRuleStore.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Admin.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed implementation of <see cref="IPlanRuleStore"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresPlanRuleStore : IPlanRuleStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly RegistryTokenPlanRuleDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<PostgresPlanRuleStore> _logger;
|
||||
|
||||
public PostgresPlanRuleStore(
|
||||
RegistryTokenPlanRuleDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresPlanRuleStore> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlanRuleDto>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT id, name, description, enabled, repositories, allowlist, rate_limit, created_at, modified_at, version
|
||||
FROM plan_rules
|
||||
ORDER BY lower(name), name, id;
|
||||
""";
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
var plans = new List<PlanRuleDto>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
plans.Add(ReadPlan(reader));
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
public async Task<PlanRuleDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT id, name, description, enabled, repositories, allowlist, rate_limit, created_at, modified_at, version
|
||||
FROM plan_rules
|
||||
WHERE id = @id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
|
||||
? ReadPlan(reader)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<PlanRuleDto?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT id, name, description, enabled, repositories, allowlist, rate_limit, created_at, modified_at, version
|
||||
FROM plan_rules
|
||||
WHERE lower(name) = lower(@name)
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("name", name.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
|
||||
? ReadPlan(reader)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<PlanRuleDto> CreateAsync(CreatePlanRequest request, string actor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
var name = request.Name.Trim();
|
||||
var description = request.Description?.Trim();
|
||||
var repositories = request.Repositories.ToList();
|
||||
var allowlist = request.Allowlist.ToList();
|
||||
var rateLimit = request.RateLimit;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = $"plan-{_guidProvider.NewGuid():N}";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (await NameExistsAsync(connection, name, null, transaction, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new PlanNameConflictException(name);
|
||||
}
|
||||
|
||||
await using (var insert = connection.CreateCommand())
|
||||
{
|
||||
insert.Transaction = transaction;
|
||||
insert.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
insert.CommandText = """
|
||||
INSERT INTO plan_rules (
|
||||
id, name, description, enabled, repositories, allowlist, rate_limit, created_at, modified_at, version
|
||||
) VALUES (
|
||||
@id, @name, @description, TRUE, @repositories, @allowlist, @rate_limit, @created_at, @modified_at, 1
|
||||
);
|
||||
""";
|
||||
|
||||
AddPlanParameters(insert, id, name, description, repositories, allowlist, rateLimit, now, now);
|
||||
await insert.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteAuditEntryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
id,
|
||||
"Created",
|
||||
actor,
|
||||
$"Created plan '{name}'",
|
||||
null,
|
||||
1,
|
||||
now,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new PlanRuleDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Enabled = true,
|
||||
Repositories = repositories,
|
||||
Allowlist = allowlist,
|
||||
RateLimit = rateLimit,
|
||||
CreatedAt = now,
|
||||
ModifiedAt = now,
|
||||
Version = 1,
|
||||
};
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new PlanNameConflictException(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlanRuleDto> UpdateAsync(string id, UpdatePlanRequest request, string actor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
var requestedName = request.Name?.Trim();
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await LoadPlanForUpdateAsync(connection, id, transaction, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new PlanNotFoundException(id);
|
||||
|
||||
if (existing.Version != request.Version)
|
||||
{
|
||||
throw new PlanVersionConflictException(id, request.Version, existing.Version);
|
||||
}
|
||||
|
||||
var resolvedName = requestedName ?? existing.Name;
|
||||
if (!string.Equals(resolvedName, existing.Name, StringComparison.OrdinalIgnoreCase) &&
|
||||
await NameExistsAsync(connection, resolvedName, id, transaction, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new PlanNameConflictException(resolvedName);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = existing with
|
||||
{
|
||||
Name = resolvedName,
|
||||
Description = request.Description is null ? existing.Description : request.Description.Trim(),
|
||||
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,
|
||||
};
|
||||
|
||||
await using (var update = connection.CreateCommand())
|
||||
{
|
||||
update.Transaction = transaction;
|
||||
update.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
update.CommandText = """
|
||||
UPDATE plan_rules
|
||||
SET name = @name,
|
||||
description = @description,
|
||||
enabled = @enabled,
|
||||
repositories = @repositories,
|
||||
allowlist = @allowlist,
|
||||
rate_limit = @rate_limit,
|
||||
modified_at = @modified_at,
|
||||
version = @version
|
||||
WHERE id = @id;
|
||||
""";
|
||||
|
||||
update.Parameters.AddWithValue("id", updated.Id);
|
||||
update.Parameters.AddWithValue("name", updated.Name);
|
||||
update.Parameters.AddWithValue("description", (object?)updated.Description ?? DBNull.Value);
|
||||
update.Parameters.AddWithValue("repositories", NpgsqlDbType.Jsonb, JsonSerializer.Serialize(updated.Repositories, JsonOptions));
|
||||
update.Parameters.AddWithValue("allowlist", NpgsqlDbType.Jsonb, JsonSerializer.Serialize(updated.Allowlist, JsonOptions));
|
||||
if (updated.RateLimit is null)
|
||||
{
|
||||
update.Parameters.AddWithValue("rate_limit", DBNull.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
update.Parameters.AddWithValue("rate_limit", NpgsqlDbType.Jsonb, JsonSerializer.Serialize(updated.RateLimit, JsonOptions));
|
||||
}
|
||||
update.Parameters.AddWithValue("modified_at", now);
|
||||
update.Parameters.AddWithValue("enabled", updated.Enabled);
|
||||
update.Parameters.AddWithValue("version", updated.Version);
|
||||
await update.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteAuditEntryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
id,
|
||||
"Updated",
|
||||
actor,
|
||||
BuildUpdateSummary(existing, updated),
|
||||
existing.Version,
|
||||
updated.Version,
|
||||
now,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new PlanNameConflictException(requestedName ?? id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string id, string actor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
PlanRuleDto? deleted = null;
|
||||
await using (var delete = connection.CreateCommand())
|
||||
{
|
||||
delete.Transaction = transaction;
|
||||
delete.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
delete.CommandText = """
|
||||
DELETE FROM plan_rules
|
||||
WHERE id = @id
|
||||
RETURNING id, name, description, enabled, repositories, allowlist, rate_limit, created_at, modified_at, version;
|
||||
""";
|
||||
delete.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await delete.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
deleted = ReadPlan(reader);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted is null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await WriteAuditEntryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
deleted.Id,
|
||||
"Deleted",
|
||||
actor,
|
||||
$"Deleted plan '{deleted.Name}'",
|
||||
deleted.Version,
|
||||
null,
|
||||
now,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlanAuditEntry>> GetAuditHistoryAsync(
|
||||
string? planId = null,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
if (string.IsNullOrWhiteSpace(planId))
|
||||
{
|
||||
command.CommandText = """
|
||||
SELECT audit_seq, plan_id, action, actor, timestamp, summary, previous_version, new_version
|
||||
FROM plan_audit
|
||||
ORDER BY timestamp DESC, audit_seq DESC
|
||||
LIMIT @limit OFFSET @offset;
|
||||
""";
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = """
|
||||
SELECT audit_seq, plan_id, action, actor, timestamp, summary, previous_version, new_version
|
||||
FROM plan_audit
|
||||
WHERE plan_id = @plan_id
|
||||
ORDER BY timestamp DESC, audit_seq DESC
|
||||
LIMIT @limit OFFSET @offset;
|
||||
""";
|
||||
command.Parameters.AddWithValue("plan_id", planId.Trim());
|
||||
}
|
||||
command.Parameters.AddWithValue("limit", pageSize);
|
||||
command.Parameters.AddWithValue("offset", (page - 1) * pageSize);
|
||||
|
||||
var entries = new List<PlanAuditEntry>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add(ReadAuditEntry(reader));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task<int> GetAuditHistoryCountAsync(string? planId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
if (string.IsNullOrWhiteSpace(planId))
|
||||
{
|
||||
command.CommandText = """
|
||||
SELECT COUNT(*)
|
||||
FROM plan_audit;
|
||||
""";
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = """
|
||||
SELECT COUNT(*)
|
||||
FROM plan_audit
|
||||
WHERE plan_id = @plan_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("plan_id", planId.Trim());
|
||||
}
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void AddPlanParameters(
|
||||
NpgsqlCommand command,
|
||||
string id,
|
||||
string name,
|
||||
string? description,
|
||||
IReadOnlyList<RepositoryRuleDto> repositories,
|
||||
IReadOnlyList<string> allowlist,
|
||||
RateLimitDto? rateLimit,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset modifiedAt)
|
||||
{
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
command.Parameters.AddWithValue("name", name);
|
||||
command.Parameters.AddWithValue("description", (object?)description ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("repositories", NpgsqlDbType.Jsonb, JsonSerializer.Serialize(repositories, JsonOptions));
|
||||
command.Parameters.AddWithValue("allowlist", NpgsqlDbType.Jsonb, JsonSerializer.Serialize(allowlist, JsonOptions));
|
||||
if (rateLimit is null)
|
||||
{
|
||||
command.Parameters.AddWithValue("rate_limit", DBNull.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
command.Parameters.AddWithValue("rate_limit", NpgsqlDbType.Jsonb, JsonSerializer.Serialize(rateLimit, JsonOptions));
|
||||
}
|
||||
command.Parameters.AddWithValue("created_at", createdAt);
|
||||
command.Parameters.AddWithValue("modified_at", modifiedAt);
|
||||
}
|
||||
|
||||
private static async Task<bool> NameExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
string name,
|
||||
string? currentId,
|
||||
NpgsqlTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandTimeout = 30;
|
||||
command.CommandText = string.IsNullOrWhiteSpace(currentId)
|
||||
? """
|
||||
SELECT 1
|
||||
FROM plan_rules
|
||||
WHERE lower(name) = lower(@name)
|
||||
LIMIT 1;
|
||||
"""
|
||||
: """
|
||||
SELECT 1
|
||||
FROM plan_rules
|
||||
WHERE lower(name) = lower(@name)
|
||||
AND id <> @current_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("name", name);
|
||||
if (!string.IsNullOrWhiteSpace(currentId))
|
||||
{
|
||||
command.Parameters.AddWithValue("current_id", currentId);
|
||||
}
|
||||
|
||||
var exists = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return exists is not null && !Equals(exists, DBNull.Value);
|
||||
}
|
||||
|
||||
private static async Task<PlanRuleDto?> LoadPlanForUpdateAsync(
|
||||
NpgsqlConnection connection,
|
||||
string id,
|
||||
NpgsqlTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandTimeout = 30;
|
||||
command.CommandText = """
|
||||
SELECT id, name, description, enabled, repositories, allowlist, rate_limit, created_at, modified_at, version
|
||||
FROM plan_rules
|
||||
WHERE id = @id
|
||||
FOR UPDATE;
|
||||
""";
|
||||
command.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
|
||||
? ReadPlan(reader)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task WriteAuditEntryAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
string planId,
|
||||
string action,
|
||||
string actor,
|
||||
string? summary,
|
||||
int? previousVersion,
|
||||
int? newVersion,
|
||||
DateTimeOffset timestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandTimeout = 30;
|
||||
command.CommandText = """
|
||||
INSERT INTO plan_audit (
|
||||
plan_id, action, actor, timestamp, summary, previous_version, new_version
|
||||
) VALUES (
|
||||
@plan_id, @action, @actor, @timestamp, @summary, @previous_version, @new_version
|
||||
);
|
||||
""";
|
||||
command.Parameters.AddWithValue("plan_id", planId);
|
||||
command.Parameters.AddWithValue("action", action);
|
||||
command.Parameters.AddWithValue("actor", actor);
|
||||
command.Parameters.AddWithValue("timestamp", timestamp);
|
||||
command.Parameters.AddWithValue("summary", (object?)summary ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("previous_version", (object?)previousVersion ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("new_version", (object?)newVersion ?? DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static PlanRuleDto ReadPlan(NpgsqlDataReader reader)
|
||||
{
|
||||
var repositories = DeserializeList<RepositoryRuleDto>(reader.GetFieldValue<string>(reader.GetOrdinal("repositories")));
|
||||
var allowlist = DeserializeList<string>(reader.GetFieldValue<string>(reader.GetOrdinal("allowlist")));
|
||||
var rateLimit = reader.IsDBNull(reader.GetOrdinal("rate_limit"))
|
||||
? null
|
||||
: JsonSerializer.Deserialize<RateLimitDto>(reader.GetFieldValue<string>(reader.GetOrdinal("rate_limit")), JsonOptions);
|
||||
|
||||
return new PlanRuleDto
|
||||
{
|
||||
Id = reader.GetString(reader.GetOrdinal("id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Description = reader.IsDBNull(reader.GetOrdinal("description")) ? null : reader.GetString(reader.GetOrdinal("description")),
|
||||
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
|
||||
Repositories = repositories,
|
||||
Allowlist = allowlist,
|
||||
RateLimit = rateLimit,
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
ModifiedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("modified_at")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
};
|
||||
}
|
||||
|
||||
private static PlanAuditEntry ReadAuditEntry(NpgsqlDataReader reader)
|
||||
{
|
||||
var auditSeq = reader.GetInt64(reader.GetOrdinal("audit_seq"));
|
||||
|
||||
return new PlanAuditEntry
|
||||
{
|
||||
Id = $"audit-{auditSeq:D8}",
|
||||
PlanId = reader.GetString(reader.GetOrdinal("plan_id")),
|
||||
Action = reader.GetString(reader.GetOrdinal("action")),
|
||||
Actor = reader.GetString(reader.GetOrdinal("actor")),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("timestamp")),
|
||||
Summary = reader.IsDBNull(reader.GetOrdinal("summary")) ? null : reader.GetString(reader.GetOrdinal("summary")),
|
||||
PreviousVersion = reader.IsDBNull(reader.GetOrdinal("previous_version")) ? null : reader.GetInt32(reader.GetOrdinal("previous_version")),
|
||||
NewVersion = reader.IsDBNull(reader.GetOrdinal("new_version")) ? null : reader.GetInt32(reader.GetOrdinal("new_version")),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> DeserializeList<T>(string json)
|
||||
=> JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
|
||||
private static string BuildUpdateSummary(PlanRuleDto existing, PlanRuleDto updated)
|
||||
{
|
||||
var changes = new List<string>();
|
||||
|
||||
if (!string.Equals(existing.Name, updated.Name, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add($"name: '{existing.Name}' -> '{updated.Name}'");
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Description, updated.Description, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add("description updated");
|
||||
}
|
||||
|
||||
if (existing.Enabled != updated.Enabled)
|
||||
{
|
||||
changes.Add($"enabled: {existing.Enabled} -> {updated.Enabled}");
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(existing.Repositories, updated.Repositories))
|
||||
{
|
||||
changes.Add("repositories updated");
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(existing.Allowlist, updated.Allowlist))
|
||||
{
|
||||
changes.Add("allowlist updated");
|
||||
}
|
||||
|
||||
if (!Equals(existing.RateLimit, updated.RateLimit))
|
||||
{
|
||||
changes.Add("rate limit updated");
|
||||
}
|
||||
|
||||
return changes.Count == 0
|
||||
? "No changes"
|
||||
: $"Updated: {string.Join(", ", changes)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// <copyright file="RegistryTokenPersistenceExtensions.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Registry.TokenService;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Admin.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Service registration helpers for registry token persistence.
|
||||
/// </summary>
|
||||
public static class RegistryTokenPersistenceExtensions
|
||||
{
|
||||
private const string PostgresSectionName = $"{RegistryTokenServiceOptions.SectionName}:Postgres";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the PostgreSQL-backed plan rule store and startup migrations.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddRegistryTokenPersistence(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var postgresSection = configuration.GetSection(PostgresSectionName);
|
||||
var schemaName = postgresSection.GetValue<string>(nameof(PostgresOptions.SchemaName));
|
||||
if (string.IsNullOrWhiteSpace(schemaName))
|
||||
{
|
||||
schemaName = RegistryTokenPlanRuleDataSource.DefaultSchemaName;
|
||||
}
|
||||
else
|
||||
{
|
||||
schemaName = schemaName.Trim();
|
||||
}
|
||||
|
||||
services.AddPostgresOptions(configuration, PostgresSectionName);
|
||||
services.PostConfigure<PostgresOptions>(options =>
|
||||
{
|
||||
options.SchemaName = string.IsNullOrWhiteSpace(options.SchemaName)
|
||||
? schemaName
|
||||
: options.SchemaName.Trim();
|
||||
});
|
||||
|
||||
services.AddStartupMigrations<PostgresOptions>(
|
||||
schemaName,
|
||||
"Registry.TokenService",
|
||||
typeof(RegistryTokenPersistenceExtensions).Assembly,
|
||||
options => options.ConnectionString);
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
new RegistryTokenPlanRuleDataSource(
|
||||
sp.GetRequiredService<IOptions<PostgresOptions>>().Value,
|
||||
sp.GetRequiredService<ILogger<RegistryTokenPlanRuleDataSource>>()));
|
||||
services.AddSingleton<IPlanRuleStore, PostgresPlanRuleStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// <copyright file="RegistryTokenPlanRuleDataSource.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Admin.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for registry token plan rules.
|
||||
/// </summary>
|
||||
public sealed class RegistryTokenPlanRuleDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default PostgreSQL schema used by the token service.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "registry_token";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new data source for registry token persistence.
|
||||
/// </summary>
|
||||
public RegistryTokenPlanRuleDataSource(
|
||||
PostgresOptions options,
|
||||
ILogger<RegistryTokenPlanRuleDataSource> logger)
|
||||
: base(Normalize(options), logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string ModuleName => "registry-token";
|
||||
|
||||
private static PostgresOptions Normalize(PostgresOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return new PostgresOptions
|
||||
{
|
||||
ConnectionString = options.ConnectionString,
|
||||
CommandTimeoutSeconds = options.CommandTimeoutSeconds,
|
||||
MaxPoolSize = options.MaxPoolSize,
|
||||
ApplicationName = options.ApplicationName,
|
||||
MinPoolSize = options.MinPoolSize,
|
||||
ConnectionIdleLifetimeSeconds = options.ConnectionIdleLifetimeSeconds,
|
||||
Pooling = options.Pooling,
|
||||
SchemaName = string.IsNullOrWhiteSpace(options.SchemaName)
|
||||
? DefaultSchemaName
|
||||
: options.SchemaName.Trim(),
|
||||
AutoMigrate = options.AutoMigrate,
|
||||
MigrationsPath = options.MigrationsPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -250,11 +250,12 @@ public static class PlanAdminEndpoints
|
||||
page = Math.Max(1, page);
|
||||
|
||||
var entries = await store.GetAuditHistoryAsync(planId, page, pageSize, cancellationToken);
|
||||
var totalCount = await store.GetAuditHistoryCountAsync(planId, cancellationToken);
|
||||
|
||||
var response = new PaginatedResponse<PlanAuditEntry>
|
||||
{
|
||||
Items = entries,
|
||||
TotalCount = entries.Count, // Note: in-memory store doesn't track total; real store should
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Registry Token Service initial persistence schema.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plan_rules (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text NULL,
|
||||
enabled boolean NOT NULL DEFAULT TRUE,
|
||||
repositories jsonb NOT NULL,
|
||||
allowlist jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
rate_limit jsonb NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
modified_at timestamptz NOT NULL,
|
||||
version integer NOT NULL CHECK (version > 0)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_plan_rules_name_lower
|
||||
ON plan_rules (lower(name));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_plan_rules_enabled
|
||||
ON plan_rules (enabled, lower(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plan_audit (
|
||||
audit_seq bigserial PRIMARY KEY,
|
||||
plan_id text NOT NULL,
|
||||
action text NOT NULL,
|
||||
actor text NOT NULL,
|
||||
timestamp timestamptz NOT NULL,
|
||||
summary text NULL,
|
||||
previous_version integer NULL,
|
||||
new_version integer NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_plan_audit_plan_timestamp
|
||||
ON plan_audit (plan_id, timestamp DESC, audit_seq DESC);
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
|
||||
namespace StellaOps.Registry.TokenService;
|
||||
|
||||
@@ -11,14 +12,21 @@ namespace StellaOps.Registry.TokenService;
|
||||
/// </summary>
|
||||
public sealed class PlanRegistry
|
||||
{
|
||||
private readonly IPlanRuleStore? _planRuleStore;
|
||||
private readonly IReadOnlyDictionary<string, PlanDescriptor> _plans;
|
||||
private readonly IReadOnlySet<string> _revokedLicenses;
|
||||
private readonly string? _defaultPlan;
|
||||
|
||||
public PlanRegistry(RegistryTokenServiceOptions options)
|
||||
: this(options, null)
|
||||
{
|
||||
}
|
||||
|
||||
public PlanRegistry(RegistryTokenServiceOptions options, IPlanRuleStore? planRuleStore)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_planRuleStore = planRuleStore;
|
||||
_plans = options.Plans
|
||||
.Select(plan => new PlanDescriptor(plan))
|
||||
.ToDictionary(static plan => plan.Name, StringComparer.OrdinalIgnoreCase);
|
||||
@@ -30,9 +38,10 @@ public sealed class PlanRegistry
|
||||
_defaultPlan = options.DefaultPlan;
|
||||
}
|
||||
|
||||
public RegistryAccessDecision Authorize(
|
||||
public async Task<RegistryAccessDecision> AuthorizeAsync(
|
||||
ClaimsPrincipal principal,
|
||||
IReadOnlyList<RegistryAccessRequest> requests)
|
||||
IReadOnlyList<RegistryAccessRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
@@ -54,14 +63,25 @@ public sealed class PlanRegistry
|
||||
planName = _defaultPlan;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(planName) || !_plans.TryGetValue(planName, out var descriptor))
|
||||
if (string.IsNullOrEmpty(planName))
|
||||
{
|
||||
return new RegistryAccessDecision(false, "plan_unknown");
|
||||
}
|
||||
|
||||
var resolvedPlan = await ResolvePlanAsync(planName, cancellationToken).ConfigureAwait(false);
|
||||
if (resolvedPlan.Disabled)
|
||||
{
|
||||
return new RegistryAccessDecision(false, "plan_disabled");
|
||||
}
|
||||
|
||||
if (resolvedPlan.Descriptor is null)
|
||||
{
|
||||
return new RegistryAccessDecision(false, "plan_unknown");
|
||||
}
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
if (!descriptor.IsRepositoryAllowed(request))
|
||||
if (!resolvedPlan.Descriptor.IsRepositoryAllowed(request))
|
||||
{
|
||||
return new RegistryAccessDecision(false, "scope_not_permitted");
|
||||
}
|
||||
@@ -70,6 +90,36 @@ public sealed class PlanRegistry
|
||||
return new RegistryAccessDecision(true);
|
||||
}
|
||||
|
||||
public RegistryAccessDecision Authorize(
|
||||
ClaimsPrincipal principal,
|
||||
IReadOnlyList<RegistryAccessRequest> requests)
|
||||
{
|
||||
return AuthorizeAsync(principal, requests).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private async Task<ResolvedPlan> ResolvePlanAsync(string planName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_planRuleStore is not null)
|
||||
{
|
||||
var plan = await _planRuleStore.GetByNameAsync(planName, cancellationToken).ConfigureAwait(false);
|
||||
if (plan is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (!plan.Enabled)
|
||||
{
|
||||
return new ResolvedPlan(null, true);
|
||||
}
|
||||
|
||||
return new ResolvedPlan(new PlanDescriptor(plan), false);
|
||||
}
|
||||
|
||||
return _plans.TryGetValue(planName, out var descriptor)
|
||||
? new ResolvedPlan(descriptor, false)
|
||||
: default;
|
||||
}
|
||||
|
||||
private sealed class PlanDescriptor
|
||||
{
|
||||
private readonly IReadOnlyList<RepositoryDescriptor> _repositories;
|
||||
@@ -82,6 +132,14 @@ public sealed class PlanRegistry
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public PlanDescriptor(PlanRuleDto source)
|
||||
{
|
||||
Name = source.Name;
|
||||
_repositories = source.Repositories
|
||||
.Select(rule => new RepositoryDescriptor(rule))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public bool IsRepositoryAllowed(RegistryAccessRequest request)
|
||||
@@ -120,6 +178,13 @@ public sealed class PlanRegistry
|
||||
_allowedActions = new HashSet<string>(rule.Actions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public RepositoryDescriptor(RepositoryRuleDto rule)
|
||||
{
|
||||
Pattern = rule.Pattern;
|
||||
_pattern = Compile(rule.Pattern);
|
||||
_allowedActions = new HashSet<string>(rule.Actions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public bool Matches(string repository)
|
||||
@@ -147,4 +212,6 @@ public sealed class PlanRegistry
|
||||
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct ResolvedPlan(PlanDescriptor? Descriptor, bool Disabled);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Registry.TokenService;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.Registry.TokenService.Admin.Persistence;
|
||||
using StellaOps.Registry.TokenService.Observability;
|
||||
using StellaOps.Telemetry.Core;
|
||||
using System.Globalization;
|
||||
@@ -36,9 +37,12 @@ builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
};
|
||||
});
|
||||
|
||||
var registryPostgresConnectionString = builder.Configuration[$"{RegistryTokenServiceOptions.SectionName}:Postgres:ConnectionString"];
|
||||
var requireStaticPlans = string.IsNullOrWhiteSpace(registryPostgresConnectionString);
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<RegistryTokenServiceOptions>(
|
||||
RegistryTokenServiceOptions.SectionName,
|
||||
(opts, _) => opts.Validate());
|
||||
(opts, _) => opts.Validate(requireStaticPlans));
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
@@ -51,20 +55,33 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
|
||||
builder.Services.AddOptions<RegistryTokenServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(RegistryTokenServiceOptions.SectionName))
|
||||
.PostConfigure(options => options.Validate())
|
||||
.PostConfigure(options => options.Validate(requireStaticPlans))
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<RegistryTokenMetrics>();
|
||||
builder.Services.AddSingleton<PlanRegistry>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<RegistryTokenServiceOptions>>().Value;
|
||||
return new PlanRegistry(options);
|
||||
});
|
||||
builder.Services.AddSingleton<RegistryTokenIssuer>();
|
||||
|
||||
// Plan Admin API dependencies
|
||||
builder.Services.AddSingleton<IPlanRuleStore, InMemoryPlanRuleStore>();
|
||||
if (!string.IsNullOrWhiteSpace(registryPostgresConnectionString))
|
||||
{
|
||||
builder.Services.AddRegistryTokenPersistence(builder.Configuration);
|
||||
}
|
||||
else if (builder.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
builder.Services.AddSingleton<IPlanRuleStore, InMemoryPlanRuleStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Missing durable plan-rule persistence configuration. Set {RegistryTokenServiceOptions.SectionName}:Postgres:ConnectionString.");
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<RegistryTokenServiceOptions>>().Value;
|
||||
return new PlanRegistry(options, sp.GetRequiredService<IPlanRuleStore>());
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlanValidator>();
|
||||
|
||||
builder.Services.AddHealthChecks().AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy());
|
||||
@@ -151,7 +168,7 @@ app.MapHealthChecks("/healthz");
|
||||
// Plan Admin API endpoints
|
||||
app.MapPlanAdminEndpoints();
|
||||
|
||||
app.MapGet("/token", (
|
||||
app.MapGet("/token", async (
|
||||
HttpContext context,
|
||||
[FromServices] IOptions<RegistryTokenServiceOptions> options,
|
||||
[FromServices] RegistryTokenIssuer issuer) =>
|
||||
@@ -195,7 +212,7 @@ app.MapGet("/token", (
|
||||
|
||||
try
|
||||
{
|
||||
var response = issuer.IssueToken(context.User, service, accessRequests);
|
||||
var response = await issuer.IssueTokenAsync(context.User, service, accessRequests, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
|
||||
@@ -42,12 +42,13 @@ public sealed class RegistryTokenIssuer
|
||||
_signingCredentials = SigningKeyLoader.Load(_options.Signing);
|
||||
}
|
||||
|
||||
public RegistryTokenResponse IssueToken(
|
||||
public async Task<RegistryTokenResponse> IssueTokenAsync(
|
||||
ClaimsPrincipal principal,
|
||||
string service,
|
||||
IReadOnlyList<RegistryAccessRequest> requests)
|
||||
IReadOnlyList<RegistryAccessRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var decision = _planRegistry.Authorize(principal, requests);
|
||||
var decision = await _planRegistry.AuthorizeAsync(principal, requests, cancellationToken).ConfigureAwait(false);
|
||||
if (!decision.Allowed)
|
||||
{
|
||||
_metrics.TokensRejected.Add(1, new KeyValuePair<string, object?>("reason", decision.FailureReason ?? "denied"));
|
||||
@@ -93,6 +94,12 @@ public sealed class RegistryTokenIssuer
|
||||
now);
|
||||
}
|
||||
|
||||
public RegistryTokenResponse IssueToken(
|
||||
ClaimsPrincipal principal,
|
||||
string service,
|
||||
IReadOnlyList<RegistryAccessRequest> requests)
|
||||
=> IssueTokenAsync(principal, service, requests).GetAwaiter().GetResult();
|
||||
|
||||
private static object BuildAccessClaim(IReadOnlyList<RegistryAccessRequest> requests)
|
||||
{
|
||||
return requests
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class RegistryTokenServiceOptions
|
||||
/// </summary>
|
||||
public string? DefaultPlan { get; set; }
|
||||
|
||||
public void Validate()
|
||||
public void Validate(bool requireStaticPlans = true)
|
||||
{
|
||||
Authority.Validate();
|
||||
Signing.Validate();
|
||||
@@ -50,7 +50,18 @@ public sealed class RegistryTokenServiceOptions
|
||||
|
||||
if (Plans.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one plan rule must be configured.");
|
||||
if (requireStaticPlans)
|
||||
{
|
||||
throw new InvalidOperationException("At least one plan rule must be configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(DefaultPlan))
|
||||
{
|
||||
DefaultPlan = DefaultPlan.Trim();
|
||||
}
|
||||
|
||||
NormalizeList(RevokedLicenses, toLower: true);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var plan in Plans)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
@@ -20,11 +21,13 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| JOBREAL-003 | DONE | Durable PostgreSQL-backed `IPlanRuleStore` is live; startup migrations and persisted `/token` authorization are proven in `SPRINT_20260415_003_DOCS_scheduler_registry_real_backend_cutover.md`. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Registry/StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -321,7 +321,7 @@ public sealed class PlanAdminEndpointsTests : IClassFixture<PlanAdminEndpointsTe
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
// Create a temporary RSA key file for signing validation
|
||||
_tempKeyPath = Path.Combine(Path.GetTempPath(), $"test-key-{Guid.NewGuid():N}.pem");
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.Registry.TokenService.Admin.Persistence;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Tests.Admin;
|
||||
|
||||
public sealed class PostgresPlanRuleStoreTests : IClassFixture<PostgresPlanRuleStoreTests.RegistryTokenPostgresFixture>
|
||||
{
|
||||
private readonly RegistryTokenPostgresFixture _fixture;
|
||||
|
||||
public PostgresPlanRuleStoreTests(RegistryTokenPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsAcrossStoreInstancesAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
var planId = await CreatePlanAsync(timeProvider);
|
||||
|
||||
await using var dataSource = CreateDataSource();
|
||||
var store = CreateStore(dataSource, timeProvider);
|
||||
|
||||
var loadedById = await store.GetByIdAsync(planId);
|
||||
Assert.NotNull(loadedById);
|
||||
Assert.Equal("postgres-plan", loadedById!.Name);
|
||||
Assert.Equal(1, loadedById.Version);
|
||||
|
||||
var loadedByName = await store.GetByNameAsync("POSTGRES-PLAN");
|
||||
Assert.NotNull(loadedByName);
|
||||
Assert.Equal(planId, loadedByName!.Id);
|
||||
|
||||
var auditCount = await store.GetAuditHistoryCountAsync(planId);
|
||||
Assert.Equal(1, auditCount);
|
||||
|
||||
var history = await store.GetAuditHistoryAsync(planId);
|
||||
Assert.Single(history);
|
||||
Assert.Equal("Created", history[0].Action);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task UpdateAndDeleteAsync_PersistAuditHistoryAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
await using var dataSource = CreateDataSource();
|
||||
var store = CreateStore(dataSource, timeProvider);
|
||||
|
||||
var created = await store.CreateAsync(
|
||||
new CreatePlanRequest
|
||||
{
|
||||
Name = "postgres-plan",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
},
|
||||
"alice");
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var updated = await store.UpdateAsync(
|
||||
created.Id,
|
||||
new UpdatePlanRequest
|
||||
{
|
||||
Description = "Updated",
|
||||
Version = created.Version,
|
||||
},
|
||||
"bob");
|
||||
|
||||
Assert.Equal(2, updated.Version);
|
||||
Assert.Equal("Updated", updated.Description);
|
||||
|
||||
var deleted = await store.DeleteAsync(created.Id, "carol");
|
||||
Assert.True(deleted);
|
||||
|
||||
await using var secondDataSource = CreateDataSource();
|
||||
var secondStore = CreateStore(secondDataSource, timeProvider);
|
||||
|
||||
var count = await secondStore.GetAuditHistoryCountAsync(created.Id);
|
||||
Assert.Equal(3, count);
|
||||
|
||||
var history = await secondStore.GetAuditHistoryAsync(created.Id, page: 1, pageSize: 10);
|
||||
Assert.Collection(history,
|
||||
entry => Assert.Equal("Deleted", entry.Action),
|
||||
entry => Assert.Equal("Updated", entry.Action),
|
||||
entry => Assert.Equal("Created", entry.Action));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateAsync_DuplicateName_ThrowsConflictAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
|
||||
await using var dataSource = CreateDataSource();
|
||||
var store = CreateStore(dataSource, timeProvider);
|
||||
|
||||
await store.CreateAsync(new CreatePlanRequest { Name = "duplicate-plan" }, "alice");
|
||||
|
||||
await Assert.ThrowsAsync<PlanNameConflictException>(() =>
|
||||
store.CreateAsync(new CreatePlanRequest { Name = "duplicate-plan" }, "bob"));
|
||||
}
|
||||
|
||||
private RegistryTokenPlanRuleDataSource CreateDataSource()
|
||||
=> new(_fixture.Fixture.CreateOptions(), NullLogger<RegistryTokenPlanRuleDataSource>.Instance);
|
||||
|
||||
private static PostgresPlanRuleStore CreateStore(
|
||||
RegistryTokenPlanRuleDataSource dataSource,
|
||||
TimeProvider timeProvider)
|
||||
=> new(dataSource, timeProvider, NullLogger<PostgresPlanRuleStore>.Instance);
|
||||
|
||||
private async Task<string> CreatePlanAsync(TimeProvider timeProvider)
|
||||
{
|
||||
await using var dataSource = CreateDataSource();
|
||||
var store = CreateStore(dataSource, timeProvider);
|
||||
|
||||
var created = await store.CreateAsync(
|
||||
new CreatePlanRequest
|
||||
{
|
||||
Name = "postgres-plan",
|
||||
Description = "Stored in postgres",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "org/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
],
|
||||
Allowlist = ["client-a"],
|
||||
},
|
||||
"alice");
|
||||
|
||||
return created.Id;
|
||||
}
|
||||
|
||||
public sealed class RegistryTokenPostgresFixture : PostgresIntegrationFixture
|
||||
{
|
||||
protected override System.Reflection.Assembly? GetMigrationAssembly() => typeof(RegistryTokenPlanRuleDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Registry.TokenService";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Registry.TokenService;
|
||||
using StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.Registry.TokenService.Admin.Persistence;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Tests.Admin;
|
||||
|
||||
public sealed class RegistryTokenPersistenceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AddRegistryTokenPersistence_RegistersPostgresStoreAndMigrations()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{RegistryTokenServiceOptions.SectionName}:Postgres:ConnectionString"] = "Host=localhost;Database=registry_token;Username=stellaops;Password=stellaops",
|
||||
[$"{RegistryTokenServiceOptions.SectionName}:Postgres:SchemaName"] = "registry_token_custom",
|
||||
})
|
||||
.Build();
|
||||
|
||||
services.AddRegistryTokenPersistence(configuration);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
var store = provider.GetRequiredService<IPlanRuleStore>();
|
||||
Assert.IsType<PostgresPlanRuleStore>(store);
|
||||
|
||||
var options = provider.GetRequiredService<IOptions<PostgresOptions>>().Value;
|
||||
Assert.Equal("registry_token_custom", options.SchemaName);
|
||||
|
||||
Assert.Contains(
|
||||
services,
|
||||
descriptor => descriptor.ServiceType == typeof(IHostedService));
|
||||
}
|
||||
}
|
||||
@@ -109,4 +109,17 @@ public sealed class PlanRegistryTests
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_AllowsDurableModeWithoutStaticPlans()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.Plans.Clear();
|
||||
options.DefaultPlan = " persisted-enterprise ";
|
||||
|
||||
options.Validate(requireStaticPlans: false);
|
||||
|
||||
Assert.Equal("persisted-enterprise", options.DefaultPlan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
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 StellaOps.Registry.TokenService.Admin;
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
|
||||
namespace StellaOps.Registry.TokenService.Tests;
|
||||
|
||||
public sealed class RegistryTokenDurableRuntimeTests : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private readonly PostgresFixture _postgres;
|
||||
private const string TenantId = "registry-durable-proof";
|
||||
|
||||
public RegistryTokenDurableRuntimeTests(PostgresFixture postgres)
|
||||
{
|
||||
_postgres = postgres;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TokenEndpoint_UsesPersistedPlanRules()
|
||||
{
|
||||
var schemaName = $"registry_token_{Guid.NewGuid():N}";
|
||||
var planName = $"enterprise-{Guid.NewGuid():N}";
|
||||
|
||||
using var factory = new DurableRegistryTokenFactory(_postgres.ConnectionString, schemaName);
|
||||
using var adminClient = factory.CreateAuthenticatedClient();
|
||||
|
||||
var createResponse = await adminClient.PostAsJsonAsync(
|
||||
"/api/admin/plans",
|
||||
new CreatePlanRequest
|
||||
{
|
||||
Name = planName,
|
||||
Description = "durable runtime proof",
|
||||
Repositories =
|
||||
[
|
||||
new RepositoryRuleDto
|
||||
{
|
||||
Pattern = "stella-ops/private/*",
|
||||
Actions = ["pull"]
|
||||
}
|
||||
]
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
using var tokenClient = factory.CreateAuthenticatedClient(planName);
|
||||
var tokenResponse = await tokenClient.GetAsync("/token?service=registry.localhost&scope=repository:stella-ops/private/cache:pull");
|
||||
Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode);
|
||||
|
||||
var tokenPayload = await tokenResponse.Content.ReadFromJsonAsync<TokenResponsePayload>();
|
||||
Assert.NotNull(tokenPayload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(tokenPayload!.Token));
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(tokenPayload.Token);
|
||||
Assert.Equal("registry.localhost", jwt.Audiences.Single());
|
||||
Assert.Equal("test-user", jwt.Subject);
|
||||
|
||||
var deniedResponse = await tokenClient.GetAsync("/token?service=registry.localhost&scope=repository:stella-ops/private/cache:push");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, deniedResponse.StatusCode);
|
||||
}
|
||||
|
||||
private sealed class TokenResponsePayload
|
||||
{
|
||||
public string Token { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class DurableRegistryTokenFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _tempKeyPath;
|
||||
|
||||
public DurableRegistryTokenFactory(string connectionString, string schemaName)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_schemaName = schemaName;
|
||||
_tempKeyPath = Path.Combine(Path.GetTempPath(), $"registry-token-test-key-{Guid.NewGuid():N}.pem");
|
||||
File.WriteAllText(_tempKeyPath, CreatePemKey());
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Production");
|
||||
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Authority:Issuer", "https://localhost:5001");
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Authority:RequireHttpsMetadata", "false");
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Signing:Issuer", "https://registry.test.local");
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Signing:KeyPath", _tempKeyPath);
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Signing:Lifetime", "00:05:00");
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Registry:Realm", "https://registry.test.local/v2/token");
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Postgres:ConnectionString", _connectionString);
|
||||
builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Postgres:SchemaName", _schemaName);
|
||||
builder.UseSetting("Telemetry:Collector:Enabled", "false");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddAuthentication("Test")
|
||||
.AddScheme<AuthenticationSchemeOptions, DurableTestAuthHandler>("Test", _ => { });
|
||||
|
||||
services.PostConfigure<AuthorizationOptions>(options =>
|
||||
{
|
||||
foreach (var policyName in new[] { "registry.admin", "registry.token.issue" })
|
||||
{
|
||||
var newPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddAuthenticationSchemes("Test")
|
||||
.Build();
|
||||
options.AddPolicy(policyName, newPolicy);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (File.Exists(_tempKeyPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(_tempKeyPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient CreateAuthenticatedClient(string? planName = null)
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(planName))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Test-Plan", planName);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DurableTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public DurableTestAuthHandler(
|
||||
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 List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "test-user"),
|
||||
new(ClaimTypes.Name, "Test User"),
|
||||
new("scope", "registry.admin registry.token.issue"),
|
||||
new("stellaops:tenant", TenantId),
|
||||
};
|
||||
|
||||
var plan = Request.Headers["X-Test-Plan"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(plan))
|
||||
{
|
||||
claims.Add(new Claim("stellaops:plan", plan));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, "Test");
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreatePemKey()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
using var writer = new StringWriter();
|
||||
writer.WriteLine("-----BEGIN PRIVATE KEY-----");
|
||||
writer.WriteLine(Convert.ToBase64String(rsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks));
|
||||
writer.WriteLine("-----END PRIVATE KEY-----");
|
||||
return writer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| JOBREAL-003 | DONE | Durable persistence, startup migrations, and Postgres-backed token issuance are covered in `RegistryTokenPersistenceTests`, `PostgresPlanRuleStoreTests`, `PlanAdminEndpointsTests`, and `RegistryTokenDurableRuntimeTests`. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/StellaOps.Registry.TokenService.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
Reference in New Issue
Block a user