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:
master
2026-04-19 14:36:05 +03:00
parent 6b89bd5652
commit 302826aedb
51 changed files with 2740 additions and 81 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
{

View File

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

View File

@@ -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)

View File

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

View File

@@ -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. |

View File

@@ -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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. |