diff --git a/src/JobEngine/README.md b/src/JobEngine/README.md index f9f76c7fb..b8e7571de 100644 --- a/src/JobEngine/README.md +++ b/src/JobEngine/README.md @@ -31,7 +31,7 @@ The JobEngine module provides scheduled scan orchestration and pack registry man - `packsregistry` (via Router) -- pack upload, download, version listing, approval workflow ## Storage -PostgreSQL schema `scheduler` (Scheduler); PostgreSQL for PacksRegistry; Valkey queue for job dispatch; seed-fs object store for artifacts +PostgreSQL schema `scheduler` (Scheduler schedules, runs, audit, and persisted resolver-job state via `scheduler.jobs`); PostgreSQL for PacksRegistry; Valkey queue for job dispatch; seed-fs object store for artifacts ## Background Workers - Scheduler: `SchedulerWorkerHostedService` -- picks up scheduled jobs from Valkey and dispatches scan runs diff --git a/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Extensions/PacksRegistryPersistenceExtensions.cs b/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Extensions/PacksRegistryPersistenceExtensions.cs index a6eb2157c..ed8c1a8b5 100644 --- a/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Extensions/PacksRegistryPersistenceExtensions.cs +++ b/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Extensions/PacksRegistryPersistenceExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using StellaOps.Infrastructure.Postgres.Migrations; using StellaOps.Infrastructure.Postgres.Options; using StellaOps.PacksRegistry.Core.Contracts; using StellaOps.PacksRegistry.Persistence.Postgres; @@ -25,6 +26,10 @@ public static class PacksRegistryPersistenceExtensions services.Configure(configuration.GetSection(sectionName)); RegisterBlobStore(services, seedFsRootPath); services.AddSingleton(); + services.AddStartupMigrations( + PacksRegistryDataSource.DefaultSchemaName, + "PacksRegistry.Persistence", + typeof(PacksRegistryDataSource).Assembly); // Register repositories services.AddSingleton(); @@ -48,6 +53,10 @@ public static class PacksRegistryPersistenceExtensions services.Configure(configureOptions); RegisterBlobStore(services, seedFsRootPath); services.AddSingleton(); + services.AddStartupMigrations( + PacksRegistryDataSource.DefaultSchemaName, + "PacksRegistry.Persistence", + typeof(PacksRegistryDataSource).Assembly); // Register repositories services.AddSingleton(); diff --git a/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Migrations/002_runtime_pack_repository_alignment.sql b/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Migrations/002_runtime_pack_repository_alignment.sql new file mode 100644 index 000000000..f0c55fda3 --- /dev/null +++ b/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Migrations/002_runtime_pack_repository_alignment.sql @@ -0,0 +1,68 @@ +-- PacksRegistry Schema Migration 002: Align live startup schema with current repository contract +-- Reconciles the legacy pack-catalog table shape from 001_initial_schema.sql with the +-- runtime repository model used by PostgresPackRepository. + +ALTER TABLE packs.pack_versions + DROP CONSTRAINT IF EXISTS fk_pack_registry_pack_versions_pack; + +ALTER TABLE packs.packs + ALTER COLUMN pack_id TYPE TEXT USING pack_id::text; + +ALTER TABLE packs.pack_versions + ALTER COLUMN pack_id TYPE TEXT USING pack_id::text; + +ALTER TABLE packs.packs + ADD COLUMN IF NOT EXISTS version TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS digest TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS signature TEXT, + ADD COLUMN IF NOT EXISTS provenance_uri TEXT, + ADD COLUMN IF NOT EXISTS provenance_digest TEXT, + ADD COLUMN IF NOT EXISTS content BYTEA NOT NULL DEFAULT '\x'::bytea, + ADD COLUMN IF NOT EXISTS provenance BYTEA; + +UPDATE packs.packs +SET version = COALESCE(NULLIF(version, ''), latest_version, 'legacy') +WHERE version = ''; + +UPDATE packs.packs +SET digest = 'legacy' +WHERE digest = ''; + +ALTER TABLE packs.packs + ALTER COLUMN display_name SET DEFAULT '', + ALTER COLUMN created_by SET DEFAULT 'packsregistry-runtime', + ALTER COLUMN updated_at SET DEFAULT NOW(); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'packs' + AND table_name = 'packs' + AND column_name = 'metadata' + AND data_type <> 'jsonb') THEN + ALTER TABLE packs.packs + ALTER COLUMN metadata TYPE JSONB + USING CASE + WHEN metadata IS NULL OR btrim(metadata) = '' THEN NULL + WHEN metadata ~ '^\s*[\{\[]' THEN metadata::jsonb + ELSE NULL + END; + END IF; +END $$; + +ALTER TABLE packs.pack_versions + ADD CONSTRAINT fk_pack_registry_pack_versions_pack + FOREIGN KEY (tenant_id, pack_id) + REFERENCES packs.packs (tenant_id, pack_id) + ON DELETE CASCADE; + +ALTER TABLE packs.packs + DROP CONSTRAINT IF EXISTS uq_pack_registry_pack_name; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_pack_registry_packs_pack_id + ON packs.packs (pack_id); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_pack_registry_packs_tenant_name_version + ON packs.packs (tenant_id, name, version); diff --git a/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/TASKS.md b/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/TASKS.md index 25716f518..050a048ce 100644 --- a/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/TASKS.md +++ b/src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/TASKS.md @@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0429-A | TODO | Revalidated 2026-01-07 (open findings). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-003 | DONE | Added seed-fs blob-store adapter and Postgres repository split (metadata in Postgres, payload bytes in object storage channel). | +| SPRINT-20260415-003 | DONE | Wired startup migrations through `AddStartupMigrations(...)` and added `002_runtime_pack_repository_alignment.sql` to reconcile the live `packs.packs` schema with current repositories. | diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryDurableRuntimeTests.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryDurableRuntimeTests.cs new file mode 100644 index 000000000..c175b3a4f --- /dev/null +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryDurableRuntimeTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.PacksRegistry.WebService.Contracts; +using StellaOps.TestKit.Fixtures; + +namespace StellaOps.PacksRegistry.Tests; + +[Collection(PacksRegistryStartupEnvironmentCollection.Name)] +public sealed class PacksRegistryDurableRuntimeTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public PacksRegistryDurableRuntimeTests(PostgresFixture postgres) + { + _postgres = postgres; + } + + [Fact] + public async Task PostgresRuntime_SurvivesRestart_ForPackAndRelatedState() + { + var tenantId = $"proof-{Guid.NewGuid():N}"; + var mirrorId = $"mirror-{Guid.NewGuid():N}"; + var seedFsRootPath = Path.Combine(Path.GetTempPath(), "packsregistry-proof", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(seedFsRootPath); + + try + { + using var environment = PacksRegistryStartupEnvironmentScope.ProductionPostgresWithConnection( + _postgres.ConnectionString, + seedFsRootPath); + + PackResponse created; + + using (var factory = new WebApplicationFactory()) + { + using var client = CreateAuthorizedClient(factory, tenantId); + + created = await UploadPackAsync(client, tenantId); + + var parityResponse = await client.PostAsJsonAsync( + $"/api/v1/packs/{created.PackId}/parity", + new ParityRequest { Status = "ready", Notes = "restart-proof" }); + Assert.Equal(HttpStatusCode.OK, parityResponse.StatusCode); + + var attestationResponse = await client.PostAsJsonAsync( + $"/api/v1/packs/{created.PackId}/attestations", + new AttestationUploadRequest + { + Type = "dsse", + Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"predicate\":\"restart-proof\"}")), + Notes = "restart-proof" + }); + Assert.Equal(HttpStatusCode.Created, attestationResponse.StatusCode); + + var lifecycleResponse = await client.PostAsJsonAsync( + $"/api/v1/packs/{created.PackId}/lifecycle", + new LifecycleRequest { State = "deprecated", Notes = "restart-proof" }); + Assert.Equal(HttpStatusCode.OK, lifecycleResponse.StatusCode); + + var mirrorResponse = await client.PostAsJsonAsync( + "/api/v1/mirrors", + new MirrorRequest + { + Id = mirrorId, + Upstream = "https://example.test/packs", + Enabled = true, + Notes = "restart-proof" + }); + Assert.Equal(HttpStatusCode.Created, mirrorResponse.StatusCode); + } + + using (var factory = new WebApplicationFactory()) + { + using var client = CreateAuthorizedClient(factory, tenantId); + + var pack = await client.GetFromJsonAsync($"/api/v1/packs/{created.PackId}"); + Assert.NotNull(pack); + Assert.Equal(created.PackId, pack!.PackId); + Assert.Equal(created.Name, pack.Name); + Assert.Equal(created.Version, pack.Version); + + var content = await client.GetAsync($"/api/v1/packs/{created.PackId}/content"); + Assert.Equal(HttpStatusCode.OK, content.StatusCode); + var contentBytes = await content.Content.ReadAsByteArrayAsync(); + Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(contentBytes)); + + var provenance = await client.GetAsync($"/api/v1/packs/{created.PackId}/provenance"); + Assert.Equal(HttpStatusCode.OK, provenance.StatusCode); + var provenanceBytes = await provenance.Content.ReadAsByteArrayAsync(); + Assert.Contains("restart-proof", System.Text.Encoding.UTF8.GetString(provenanceBytes)); + + var parity = await client.GetFromJsonAsync($"/api/v1/packs/{created.PackId}/parity"); + Assert.NotNull(parity); + Assert.Equal("ready", parity!.Status); + + var lifecycle = await client.GetFromJsonAsync($"/api/v1/packs/{created.PackId}/lifecycle"); + Assert.NotNull(lifecycle); + Assert.Equal("deprecated", lifecycle!.State); + + var attestations = await client.GetFromJsonAsync($"/api/v1/packs/{created.PackId}/attestations"); + Assert.NotNull(attestations); + Assert.Contains(attestations!, item => string.Equals(item.Type, "dsse", StringComparison.OrdinalIgnoreCase)); + + var attestationContent = await client.GetAsync($"/api/v1/packs/{created.PackId}/attestations/dsse"); + Assert.Equal(HttpStatusCode.OK, attestationContent.StatusCode); + + var activePacks = await client.GetFromJsonAsync($"/api/v1/packs?tenant={tenantId}"); + Assert.NotNull(activePacks); + Assert.DoesNotContain(activePacks!, item => string.Equals(item.PackId, created.PackId, StringComparison.OrdinalIgnoreCase)); + + var allPacks = await client.GetFromJsonAsync($"/api/v1/packs?tenant={tenantId}&includeDeprecated=true"); + Assert.NotNull(allPacks); + Assert.Contains(allPacks!, item => string.Equals(item.PackId, created.PackId, StringComparison.OrdinalIgnoreCase)); + + var mirrors = await client.GetFromJsonAsync($"/api/v1/mirrors?tenant={tenantId}"); + Assert.NotNull(mirrors); + Assert.Contains(mirrors!, item => string.Equals(item.Id, mirrorId, StringComparison.OrdinalIgnoreCase)); + + var compliance = await client.GetFromJsonAsync($"/api/v1/compliance/summary?tenant={tenantId}"); + Assert.NotNull(compliance); + Assert.Equal(1, compliance!.TotalPacks); + Assert.Equal(1, compliance.AttestedPacks); + } + + var tenantSql = tenantId.Replace("'", "''", StringComparison.Ordinal); + var packIdSql = created.PackId.Replace("'", "''", StringComparison.Ordinal); + var mirrorIdSql = mirrorId.Replace("'", "''", StringComparison.Ordinal); + + await _postgres.ExecuteSqlAsync( + $""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM packs.packs WHERE pack_id = '{packIdSql}' AND tenant_id = '{tenantSql}') THEN + RAISE EXCEPTION 'missing persisted pack row'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.parities WHERE pack_id = '{packIdSql}' AND tenant_id = '{tenantSql}' AND status = 'ready') THEN + RAISE EXCEPTION 'missing persisted parity row'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.lifecycles WHERE pack_id = '{packIdSql}' AND tenant_id = '{tenantSql}' AND state = 'deprecated') THEN + RAISE EXCEPTION 'missing persisted lifecycle row'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.attestations WHERE pack_id = '{packIdSql}' AND type = 'dsse') THEN + RAISE EXCEPTION 'missing persisted attestation row'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.mirror_sources WHERE id = '{mirrorIdSql}' AND tenant_id = '{tenantSql}') THEN + RAISE EXCEPTION 'missing persisted mirror row'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.audit_log WHERE tenant_id = '{tenantSql}' AND event = 'pack.uploaded') THEN + RAISE EXCEPTION 'missing audit event pack.uploaded'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.audit_log WHERE tenant_id = '{tenantSql}' AND event = 'parity.updated') THEN + RAISE EXCEPTION 'missing audit event parity.updated'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.audit_log WHERE tenant_id = '{tenantSql}' AND event = 'lifecycle.updated') THEN + RAISE EXCEPTION 'missing audit event lifecycle.updated'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.audit_log WHERE tenant_id = '{tenantSql}' AND event = 'attestation.uploaded') THEN + RAISE EXCEPTION 'missing audit event attestation.uploaded'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM packs.audit_log WHERE tenant_id = '{tenantSql}' AND event = 'mirror.upserted') THEN + RAISE EXCEPTION 'missing audit event mirror.upserted'; + END IF; + END $$; + """); + } + finally + { + if (Directory.Exists(seedFsRootPath)) + { + Directory.Delete(seedFsRootPath, recursive: true); + } + } + } + + private static HttpClient CreateAuthorizedClient(WebApplicationFactory factory, string tenantId) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId); + + var auth = factory.Services.GetRequiredService(); + if (!string.IsNullOrWhiteSpace(auth.ApiKey)) + { + client.DefaultRequestHeaders.Add("X-API-Key", auth.ApiKey); + } + + return client; + } + + private static async Task UploadPackAsync(HttpClient client, string tenantId) + { + var payload = new PackUploadRequest + { + Name = $"demo-{tenantId[..8]}", + Version = $"1.0.{Math.Abs(tenantId.GetHashCode(StringComparison.Ordinal)) % 1000:D3}", + TenantId = tenantId, + Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello")), + ProvenanceUri = "https://example.test/provenance.json", + ProvenanceContent = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"restart-proof\":true}")) + }; + + var response = await client.PostAsJsonAsync("/api/v1/packs", payload); + if (response.StatusCode != HttpStatusCode.Created) + { + var body = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Pack upload failed with {(int)response.StatusCode} {response.StatusCode}: {body}"); + } + + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + return created!; + } +} diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs index 2f6465897..0c9902119 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupContractTests.cs @@ -1,11 +1,19 @@ using System.Net; using Microsoft.AspNetCore.Mvc.Testing; +using StellaOps.TestKit.Fixtures; namespace StellaOps.PacksRegistry.Tests; [Collection(PacksRegistryStartupEnvironmentCollection.Name)] -public sealed class PacksRegistryStartupContractTests +public sealed class PacksRegistryStartupContractTests : IClassFixture { + private readonly PostgresFixture _postgres; + + public PacksRegistryStartupContractTests(PostgresFixture postgres) + { + _postgres = postgres; + } + [Fact] public void Startup_FailsWithoutPostgresConnectionString_InProduction() { @@ -60,11 +68,56 @@ public sealed class PacksRegistryStartupContractTests [Fact] public async Task Startup_AllowsSeedFsObjectStoreDriver() { - using var environment = PacksRegistryStartupEnvironmentScope.TestingInMemorySeedFs(); + using var environment = PacksRegistryStartupEnvironmentScope.TestingFilesystemSeedFs(); using var factory = new WebApplicationFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task Startup_PostgresDriver_AppliesStartupMigrations() + { + using var environment = PacksRegistryStartupEnvironmentScope.ProductionPostgresWithConnection(_postgres.ConnectionString); + using var factory = new WebApplicationFactory(); + + using var client = factory.CreateClient(); + var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await _postgres.ExecuteSqlAsync( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'packs') THEN + RAISE EXCEPTION 'missing schema packs'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'packs' AND tablename = 'packs') THEN + RAISE EXCEPTION 'missing table packs.packs'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'packs' AND table_name = 'packs' AND column_name = 'version') THEN + RAISE EXCEPTION 'missing column packs.packs.version'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'packs' AND table_name = 'packs' AND column_name = 'digest') THEN + RAISE EXCEPTION 'missing column packs.packs.digest'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'packs' AND table_name = 'packs' AND column_name = 'content') THEN + RAISE EXCEPTION 'missing column packs.packs.content'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'packs' AND table_name = 'packs' AND column_name = 'metadata' AND data_type = 'jsonb') THEN + RAISE EXCEPTION 'missing jsonb column packs.packs.metadata'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'packs' AND tablename = 'audit_log') THEN + RAISE EXCEPTION 'missing table packs.audit_log'; + END IF; + END $$; + """); + } } diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs index 0753f8a61..13c881fa0 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/PacksRegistryStartupEnvironmentScope.cs @@ -65,13 +65,13 @@ internal sealed class PacksRegistryStartupEnvironmentScope : IDisposable return scope; } - public static PacksRegistryStartupEnvironmentScope TestingInMemorySeedFs() + public static PacksRegistryStartupEnvironmentScope TestingFilesystemSeedFs() { var scope = new PacksRegistryStartupEnvironmentScope(); scope.Set("DOTNET_ENVIRONMENT", "Testing"); scope.Set("ASPNETCORE_ENVIRONMENT", "Testing"); - scope.Set("STORAGE__DRIVER", "inmemory"); - scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "inmemory"); + scope.Set("STORAGE__DRIVER", "filesystem"); + scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "filesystem"); scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null); @@ -81,6 +81,24 @@ internal sealed class PacksRegistryStartupEnvironmentScope : IDisposable return scope; } + public static PacksRegistryStartupEnvironmentScope ProductionPostgresWithConnection(string connectionString, string? seedFsRootPath = null) + { + var scope = new PacksRegistryStartupEnvironmentScope(); + scope.Set("DOTNET_ENVIRONMENT", "Production"); + scope.Set("ASPNETCORE_ENVIRONMENT", "Production"); + scope.Set("STORAGE__DRIVER", "postgres"); + scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "postgres"); + scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs"); + scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); + scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString); + scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", connectionString); + scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString); + scope.Set("STORAGE__OBJECTSTORE__SEEDFS__ROOTPATH", seedFsRootPath); + scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__SEEDFS__ROOTPATH", seedFsRootPath); + return scope; + } + public void Dispose() { foreach (var entry in _originalValues) diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md index 4b746f2b2..59f0515a7 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/TASKS.md @@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0432-A | DONE | Waived (test project; revalidated 2026-01-07). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-20260305-002 | DONE | Added `PacksRegistryStartupContractTests` covering postgres missing-connection fail-fast and seed-fs/rustfs object-store contract enforcement. | +| SPRINT-20260415-003 | DONE | Added `PacksRegistryDurableRuntimeTests` and expanded startup migration assertions to prove Postgres + seed-fs restart survival with the live host composition. | diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs index c32bde562..adfe24b5b 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs @@ -4,7 +4,6 @@ using StellaOps.PacksRegistry.Core.Contracts; using StellaOps.PacksRegistry.Core.Models; using StellaOps.PacksRegistry.Core.Services; using StellaOps.PacksRegistry.Infrastructure.FileSystem; -using StellaOps.PacksRegistry.Infrastructure.InMemory; using StellaOps.PacksRegistry.Persistence.Extensions; using StellaOps.PacksRegistry.Infrastructure.Verification; using StellaOps.PacksRegistry.WebService; @@ -62,14 +61,10 @@ else if (string.Equals(storageDriver, "filesystem", StringComparison.OrdinalIgno { RegisterFilesystemStores(builder.Services, dataDir); } -else if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase)) -{ - RegisterInMemoryStores(builder.Services); -} else { throw new InvalidOperationException( - $"Unsupported PacksRegistry storage driver '{storageDriver}'. Allowed values: postgres, filesystem, inmemory."); + $"Unsupported PacksRegistry storage driver '{storageDriver}'. Allowed values: postgres, filesystem."); } ValidateObjectStoreContract( @@ -863,16 +858,6 @@ static void RegisterFilesystemStores(IServiceCollection services, string dataDir services.AddSingleton(_ => new FileMirrorRepository(dataDirectory)); } -static void RegisterInMemoryStores(IServiceCollection services) -{ - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); -} - static string ResolveStorageDriver(IConfiguration configuration, string serviceName) { return FirstNonEmpty( diff --git a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md index e529ce41a..3e48fb12b 100644 --- a/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md +++ b/src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/TASKS.md @@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | | SPRINT-312-003 | DONE | Postgres-first storage driver migration with seed-fs payload contract wired in Program startup (pack/provenance/attestation payload channel). | | SPRINT-20260305-002 | DONE | Finalized startup contract: seed-fs is the only accepted object-store driver; rustfs/unknown drivers fail fast with deterministic error messages. | +| SPRINT-20260415-003 | DONE | Removed the live `RegisterInMemoryStores` branch; the web host now supports only `postgres` and `filesystem` storage drivers. | diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs index b164215fd..02b5555cd 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs @@ -44,7 +44,7 @@ internal sealed class SystemScheduleBootstrap : BackgroundService ]; // TODO: Replace with real multi-tenant resolution when available. - private static readonly string[] Tenants = ["demo-prod"]; + private static readonly string[] Tenants = ["default"]; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs index 322b400ed..9fcf77a7d 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs @@ -23,6 +23,7 @@ using StellaOps.Scheduler.WebService.FailureSignatures; using StellaOps.Scheduler.WebService.GraphJobs; using StellaOps.Scheduler.WebService.GraphJobs.Events; using StellaOps.Scheduler.WebService.Bootstrap; +using StellaOps.Scheduler.WebService.Configuration; using StellaOps.Scheduler.WebService.Hosting; using StellaOps.Scheduler.WebService.Observability; using StellaOps.Scheduler.WebService.Options; @@ -88,7 +89,6 @@ builder.Services.AddOptions() }); builder.Services.AddMemoryCache(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -98,23 +98,30 @@ builder.Services.AddSingleton(cartographerOptions); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Scheduler:Cartographer")); -var storageSection = builder.Configuration.GetSection("Scheduler:Storage"); -if (storageSection.Exists()) +var storageSectionPath = SchedulerStorageConfiguration.ResolveSectionPath(builder.Configuration); +var storageConnectionString = SchedulerStorageConfiguration.ResolveConnectionString(builder.Configuration); +var hasSchedulerStorage = !string.IsNullOrWhiteSpace(storageConnectionString); +if (hasSchedulerStorage) { - builder.Services.AddSchedulerPersistence(storageSection); - builder.Services.AddScoped(); + builder.Services.AddSchedulerPersistence(builder.Configuration, sectionName: storageSectionPath!); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static sp => (IPolicySimulationMetricsRecorder)sp.GetRequiredService()); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } -else +else if (builder.Environment.IsEnvironment("Testing")) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -123,6 +130,14 @@ else builder.Services.AddSingleton(); builder.Services.AddSingleton(); } +else +{ + throw new InvalidOperationException( + "Scheduler requires a configured storage connection string outside the Testing environment. Supported keys: Scheduler:Storage:ConnectionString, Scheduler:Storage:Postgres:Scheduler:ConnectionString, or Postgres:Scheduler:ConnectionString."); +} + +builder.Services.AddSchedulerWebhookRateLimiter(builder.Configuration, builder.Environment); + // Workflow engine HTTP client (starts workflow instances for system schedules) builder.Services.AddHttpClient((sp, client) => { @@ -131,7 +146,6 @@ builder.Services.AddHttpClient(); -builder.Services.AddSingleton(); if (cartographerOptions.Webhook.Enabled) { builder.Services.AddHttpClient((serviceProvider, client) => @@ -146,7 +160,7 @@ else } builder.Services.AddScoped(); builder.Services.AddImpactIndex(); -builder.Services.AddResolverJobServices(); +builder.Services.AddResolverJobServices(hasSchedulerStorage); // Embedded worker mode: when Scheduler:Worker:Embedded is true (default), // all 8 BackgroundServices (6 heavy workers + 2 exception workers) run in this diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/PostgresSchedulerAuditService.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/PostgresSchedulerAuditService.cs new file mode 100644 index 000000000..98bb73deb --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/PostgresSchedulerAuditService.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Determinism; +using StellaOps.Scheduler.Models; +using StellaOps.Scheduler.Persistence.Postgres; + +namespace StellaOps.Scheduler.WebService.Schedules; + +internal sealed class PostgresSchedulerAuditService : ISchedulerAuditService +{ + private readonly SchedulerDataSource _dataSource; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; + private readonly ILogger _logger; + + public PostgresSchedulerAuditService( + SchedulerDataSource dataSource, + ILogger logger, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) + { + _dataSource = dataSource; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; + _logger = logger; + } + + public async Task WriteAsync( + SchedulerAuditEvent auditEvent, + CancellationToken cancellationToken = default) + { + var occurredAt = auditEvent.OccurredAt ?? _timeProvider.GetUtcNow(); + var record = new AuditRecord( + auditEvent.AuditId ?? $"audit_{_guidProvider.NewGuid():N}", + auditEvent.TenantId, + auditEvent.Category, + auditEvent.Action, + occurredAt, + auditEvent.Actor, + auditEvent.EntityId, + auditEvent.ScheduleId, + auditEvent.RunId, + auditEvent.CorrelationId, + auditEvent.Metadata?.ToArray(), + auditEvent.Message); + + var resourceId = auditEvent.EntityId + ?? auditEvent.ScheduleId + ?? auditEvent.RunId; + + var payload = JsonSerializer.Serialize(record); + + await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO scheduler.audit ( + tenant_id, + user_id, + action, + resource_type, + resource_id, + old_value, + new_value, + correlation_id, + created_at + ) + VALUES ( + @tenant_id, + @user_id, + @action, + @resource_type, + @resource_id, + NULL, + @new_value::jsonb, + @correlation_id, + @created_at + ) + """; + + command.Parameters.AddWithValue("tenant_id", record.TenantId); + command.Parameters.AddWithValue("user_id", DBNull.Value); + command.Parameters.AddWithValue("action", record.Action); + command.Parameters.AddWithValue("resource_type", record.Category); + command.Parameters.AddWithValue("resource_id", (object?)resourceId ?? DBNull.Value); + command.Parameters.AddWithValue("new_value", payload); + command.Parameters.AddWithValue("correlation_id", (object?)record.CorrelationId ?? DBNull.Value); + command.Parameters.AddWithValue("created_at", record.OccurredAt); + + var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + if (rows != 1) + { + _logger.LogWarning( + "Expected a single scheduler.audit row for {AuditId} but inserted {Rows} rows.", + record.Id, + rows); + } + + return record; + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/TASKS.md b/src/JobEngine/StellaOps.Scheduler.WebService/TASKS.md index 2bf1ed696..1c4af08e5 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/TASKS.md +++ b/src/JobEngine/StellaOps.Scheduler.WebService/TASKS.md @@ -1,9 +1,11 @@ # StellaOps.Scheduler.WebService Task Board This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. +Source of truth: `docs/implplan/SPRINT_20260415_003_DOCS_scheduler_registry_real_backend_cutover.md`, `docs/implplan/SPRINT_20260417_019_JobEngine_truthful_webhook_rate_limiter_runtime.md`. | Task ID | Status | Notes | | --- | --- | --- | +| JOBREAL-001 | DONE | Live Scheduler now fails fast without `Scheduler:Storage:ConnectionString`; in-memory repositories and resolver-job state are restricted to the `Testing` environment, with proof in `SchedulerStartupContractTests` (`2/2`), `SchedulerDurableHostTests` (`3/3`), `ScheduleEndpointTests` (`2/2`), and `SchedulerJwtAuthTests` (`10/10`). | +| JOBREAL-WEBHOOK-001 | DONE | Non-testing Scheduler webhook rate limiting no longer uses `InMemoryWebhookRateLimiter`; runtime now requires Redis-backed `scheduler:queue` for enabled inbound webhooks, with proof in `WebhookRateLimiterRuntimeTests` (registration `4/4`, Redis proof `1/1`). | | SPRINT_20260405_011-XPORT-VALKEY | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the scheduler graph-job event Valkey client construction path. | | QA-SCHED-VERIFY-002 | DONE | `scheduler-graph-job-dtos` verified (run-001 Tier 0/1/2 pass); scheduler verification batch completed with `QA-SCHED-VERIFY-003` terminalized as `not_implemented` (run-001 Tier 0 evidence). | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scheduler/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.md. | diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/IResolverJobService.cs b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/IResolverJobService.cs index 31ded5d0f..f087e84c4 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/IResolverJobService.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/IResolverJobService.cs @@ -6,5 +6,5 @@ public interface IResolverJobService { Task CreateAsync(string tenantId, ResolverJobRequest request, CancellationToken cancellationToken); Task GetAsync(string tenantId, string jobId, CancellationToken cancellationToken); - ResolverBacklogMetricsResponse ComputeMetrics(string tenantId); + Task ComputeMetricsAsync(string tenantId, CancellationToken cancellationToken); } diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/InMemoryResolverJobService.cs b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/InMemoryResolverJobService.cs index b2c97fe12..b4baef57f 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/InMemoryResolverJobService.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/InMemoryResolverJobService.cs @@ -4,8 +4,8 @@ using System.ComponentModel.DataAnnotations; namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; /// -/// Lightweight in-memory resolver job service to satisfy API contract and rate-limit callers. -/// Suitable for stub/air-gap scenarios; replace with PostgreSQL-backed implementation when ready. +/// Test-only in-memory resolver job service used by the Scheduler web test host when durable storage is absent. +/// Live hosts must use the PostgreSQL-backed implementation. /// public sealed class InMemoryResolverJobService : IResolverJobService { @@ -54,7 +54,7 @@ public sealed class InMemoryResolverJobService : IResolverJobService return Task.FromResult(response); } - public ResolverBacklogMetricsResponse ComputeMetrics(string tenantId) + public Task ComputeMetricsAsync(string tenantId, CancellationToken cancellationToken) { var now = _timeProvider.GetUtcNow(); var pending = new List(); @@ -84,7 +84,7 @@ public sealed class InMemoryResolverJobService : IResolverJobService .OrderByDescending(e => e.CompletedAt) .ToList(); - return new ResolverBacklogMetricsResponse( + return Task.FromResult(new ResolverBacklogMetricsResponse( tenantId, Pending: pending.Count, Running: 0, @@ -93,7 +93,7 @@ public sealed class InMemoryResolverJobService : IResolverJobService MinLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Min(e => (double?)e.LagSeconds), MaxLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Max(e => (double?)e.LagSeconds), AverageLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Average(e => e.LagSeconds), - RecentCompleted: lagEntries.Take(5).ToList()); + RecentCompleted: lagEntries.Take(5).ToList())); } private static void ValidateRequest(ResolverJobRequest request) diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/PostgresResolverJobService.cs b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/PostgresResolverJobService.cs new file mode 100644 index 000000000..ff92954bd --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/PostgresResolverJobService.cs @@ -0,0 +1,319 @@ +using System.ComponentModel.DataAnnotations; +using System.Data.Common; +using System.Security.Cryptography; +using System.Text; +using StellaOps.Scheduler.Persistence.Postgres; +using StellaOps.Scheduler.Persistence.Postgres.Models; +using StellaOps.Scheduler.Persistence.Postgres.Repositories; +using StellaOps.Scheduler.Models; + +namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; + +public sealed class PostgresResolverJobService : IResolverJobService +{ + private const string JobType = "resolver"; + private const string JobIdPrefix = "resolver-"; + private const string CreatedBy = "scheduler-web:resolver"; + private readonly IJobRepository _jobRepository; + private readonly SchedulerDataSource _dataSource; + + public PostgresResolverJobService(IJobRepository jobRepository, SchedulerDataSource dataSource) + { + _jobRepository = jobRepository ?? throw new ArgumentNullException(nameof(jobRepository)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task CreateAsync(string tenantId, ResolverJobRequest request, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(request); + ValidateRequest(request); + + var payload = new PersistedResolverJobPayload( + request.ArtifactId.Trim(), + request.PolicyId.Trim(), + request.CorrelationId, + NormalizeMetadata(request.Metadata)); + + var payloadJson = CanonicalJsonSerializer.Serialize(payload); + var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))); + var idempotencyKey = $"resolver:{digest}"; + + var existing = await _jobRepository.GetByIdempotencyKeyAsync(tenantId, idempotencyKey, cancellationToken).ConfigureAwait(false); + if (existing is not null && string.Equals(existing.JobType, JobType, StringComparison.OrdinalIgnoreCase)) + { + return MapResponse(existing); + } + + var entity = new JobEntity + { + Id = Guid.NewGuid(), + TenantId = tenantId, + JobType = JobType, + Status = JobStatus.Scheduled, + Priority = 0, + Payload = payloadJson, + PayloadDigest = $"sha256:{digest}", + IdempotencyKey = idempotencyKey, + CorrelationId = request.CorrelationId, + MaxAttempts = 3, + CreatedBy = CreatedBy + }; + + try + { + var created = await _jobRepository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); + return MapResponse(created); + } + catch (Exception ex) when (IsUniqueViolation(ex)) + { + existing = await _jobRepository.GetByIdempotencyKeyAsync(tenantId, idempotencyKey, cancellationToken).ConfigureAwait(false); + if (existing is not null && string.Equals(existing.JobType, JobType, StringComparison.OrdinalIgnoreCase)) + { + return MapResponse(existing); + } + + throw; + } + } + + public async Task GetAsync(string tenantId, string jobId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + if (!TryParseExternalId(jobId, out var internalId)) + { + return null; + } + + var entity = await _jobRepository.GetByIdAsync(tenantId, internalId, cancellationToken).ConfigureAwait(false); + if (entity is null || !string.Equals(entity.JobType, JobType, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return MapResponse(entity); + } + + public async Task ComputeMetricsAsync(string tenantId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var summary = await GetAggregateMetricsAsync(tenantId, cancellationToken).ConfigureAwait(false); + var recentCompleted = await GetRecentCompletedAsync(tenantId, cancellationToken).ConfigureAwait(false); + + return new ResolverBacklogMetricsResponse( + tenantId, + summary.Pending, + summary.Running, + summary.Completed, + summary.Failed, + summary.MinLagSeconds, + summary.MaxLagSeconds, + summary.AverageLagSeconds, + recentCompleted); + } + + private async Task GetAggregateMetricsAsync(string tenantId, CancellationToken cancellationToken) + { + const string sql = """ + SELECT + COUNT(*) FILTER (WHERE status IN ('pending', 'scheduled')) AS pending, + COUNT(*) FILTER (WHERE status IN ('leased', 'running')) AS running, + COUNT(*) FILTER (WHERE status = 'succeeded') AS completed, + COUNT(*) FILTER (WHERE status IN ('failed', 'timed_out', 'canceled')) AS failed, + MIN(EXTRACT(EPOCH FROM completed_at - created_at)) FILTER (WHERE status = 'succeeded' AND completed_at IS NOT NULL) AS min_lag_seconds, + MAX(EXTRACT(EPOCH FROM completed_at - created_at)) FILTER (WHERE status = 'succeeded' AND completed_at IS NOT NULL) AS max_lag_seconds, + AVG(EXTRACT(EPOCH FROM completed_at - created_at)) FILTER (WHERE status = 'succeeded' AND completed_at IS NOT NULL) AS avg_lag_seconds + FROM scheduler.jobs + WHERE tenant_id = @tenant_id + AND job_type = @job_type + """; + + await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + AddParameter(command, "tenant_id", tenantId); + AddParameter(command, "job_type", JobType); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + + return new AggregateMetrics( + reader.GetInt32(reader.GetOrdinal("pending")), + reader.GetInt32(reader.GetOrdinal("running")), + reader.GetInt32(reader.GetOrdinal("completed")), + reader.GetInt32(reader.GetOrdinal("failed")), + GetNullableDouble(reader, reader.GetOrdinal("min_lag_seconds")), + GetNullableDouble(reader, reader.GetOrdinal("max_lag_seconds")), + GetNullableDouble(reader, reader.GetOrdinal("avg_lag_seconds"))); + } + + private async Task> GetRecentCompletedAsync(string tenantId, CancellationToken cancellationToken) + { + const string sql = """ + SELECT id, created_at, completed_at, correlation_id, payload + FROM scheduler.jobs + WHERE tenant_id = @tenant_id + AND job_type = @job_type + AND status = 'succeeded' + AND completed_at IS NOT NULL + ORDER BY completed_at DESC, id DESC + LIMIT 5 + """; + + var entries = new List(); + + await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + AddParameter(command, "tenant_id", tenantId); + AddParameter(command, "job_type", JobType); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var completedAt = reader.GetFieldValue(reader.GetOrdinal("completed_at")); + var createdAt = reader.GetFieldValue(reader.GetOrdinal("created_at")); + var payload = CanonicalJsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("payload"))); + var correlationId = reader.IsDBNull(reader.GetOrdinal("correlation_id")) + ? payload.CorrelationId + : reader.GetString(reader.GetOrdinal("correlation_id")); + + entries.Add(new ResolverLagEntry( + ToExternalId(reader.GetGuid(reader.GetOrdinal("id"))), + completedAt, + Math.Max((completedAt - createdAt).TotalSeconds, 0d), + correlationId, + payload.ArtifactId, + payload.PolicyId)); + } + + return entries; + } + + private static ResolverJobResponse MapResponse(JobEntity entity) + { + var payload = CanonicalJsonSerializer.Deserialize(entity.Payload); + + return new ResolverJobResponse( + ToExternalId(entity.Id), + payload.ArtifactId, + payload.PolicyId, + MapStatus(entity.Status), + entity.CreatedAt, + entity.CompletedAt, + entity.CorrelationId ?? payload.CorrelationId, + payload.Metadata); + } + + private static IReadOnlyDictionary NormalizeMetadata(IReadOnlyDictionary? metadata) + { + if (metadata is null || metadata.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + return metadata + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + } + + private static string MapStatus(JobStatus status) => status switch + { + JobStatus.Pending => "queued", + JobStatus.Scheduled => "queued", + JobStatus.Leased => "running", + JobStatus.Running => "running", + JobStatus.Succeeded => "completed", + JobStatus.Failed => "failed", + JobStatus.Canceled => "failed", + JobStatus.TimedOut => "failed", + _ => "queued" + }; + + private static string ToExternalId(Guid id) => JobIdPrefix + id.ToString("N"); + + private static bool TryParseExternalId(string jobId, out Guid id) + { + if (Guid.TryParse(jobId, out id)) + { + return true; + } + + if (jobId.StartsWith(JobIdPrefix, StringComparison.OrdinalIgnoreCase)) + { + var value = jobId[JobIdPrefix.Length..]; + return Guid.TryParseExact(value, "N", out id) || Guid.TryParse(value, out id); + } + + id = Guid.Empty; + return false; + } + + private static bool IsUniqueViolation(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException!) + { + if (current is Npgsql.PostgresException postgresException && + string.Equals(postgresException.SqlState, Npgsql.PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal)) + { + return true; + } + + if (current.InnerException is null) + { + break; + } + } + + return false; + } + + private static void ValidateRequest(ResolverJobRequest request) + { + if (string.IsNullOrWhiteSpace(request.ArtifactId)) + { + throw new ValidationException("artifactId is required."); + } + + if (string.IsNullOrWhiteSpace(request.PolicyId)) + { + throw new ValidationException("policyId is required."); + } + } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + private static double? GetNullableDouble(DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return Convert.ToDouble(reader.GetValue(ordinal)); + } + + private sealed record PersistedResolverJobPayload( + string ArtifactId, + string PolicyId, + string? CorrelationId, + IReadOnlyDictionary Metadata); + + private sealed record AggregateMetrics( + int Pending, + int Running, + int Completed, + int Failed, + double? MinLagSeconds, + double? MaxLagSeconds, + double? AverageLagSeconds); +} diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobEndpointExtensions.cs b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobEndpointExtensions.cs index c52fc8b8b..47652bf7a 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobEndpointExtensions.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobEndpointExtensions.cs @@ -84,19 +84,20 @@ public static class ResolverJobEndpointExtensions } } - internal static IResult GetLagMetricsAsync( + internal static async Task GetLagMetricsAsync( HttpContext httpContext, [FromServices] ITenantContextAccessor tenantAccessor, [FromServices] IScopeAuthorizer authorizer, [FromServices] IResolverJobService jobService, [FromServices] IResolverBacklogService backlogService, - [FromServices] IResolverBacklogNotifier backlogNotifier) + [FromServices] IResolverBacklogNotifier backlogNotifier, + CancellationToken cancellationToken) { try { authorizer.EnsureScope(httpContext, ScopeRead); var tenant = tenantAccessor.GetTenant(httpContext); - var metrics = jobService.ComputeMetrics(tenant.TenantId); + var metrics = await jobService.ComputeMetricsAsync(tenant.TenantId, cancellationToken).ConfigureAwait(false); var backlog = backlogService.GetSummary(); backlogNotifier.NotifyIfBreached(metrics with { Pending = (int)backlog.TotalDepth }); return Results.Ok(new { jobs = metrics, backlog }); diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobServiceCollectionExtensions.cs b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobServiceCollectionExtensions.cs index 5c38d0674..949990216 100644 --- a/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobServiceCollectionExtensions.cs +++ b/src/JobEngine/StellaOps.Scheduler.WebService/VulnerabilityResolverJobs/ResolverJobServiceCollectionExtensions.cs @@ -1,13 +1,23 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; public static class ResolverJobServiceCollectionExtensions { - public static IServiceCollection AddResolverJobServices(this IServiceCollection services) + public static IServiceCollection AddResolverJobServices(this IServiceCollection services, bool usePersistentStorage) { - services.AddSingleton(); + services.RemoveAll(); + if (usePersistentStorage) + { + services.AddScoped(); + } + else + { + services.AddSingleton(); + } + services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/JobEngine/StellaOps.Scheduler.Worker.Host/Program.cs b/src/JobEngine/StellaOps.Scheduler.Worker.Host/Program.cs index 2179bc186..c622bf568 100644 --- a/src/JobEngine/StellaOps.Scheduler.Worker.Host/Program.cs +++ b/src/JobEngine/StellaOps.Scheduler.Worker.Host/Program.cs @@ -29,7 +29,7 @@ if (storageSection.Exists()) } builder.Services.AddSchedulerWorker(builder.Configuration.GetSection("Scheduler:Worker")); -builder.Services.AddImpactIndexStub(); +builder.Services.AddImpactIndex(); builder.Services.AddWorkerHealthChecks(); @@ -37,5 +37,5 @@ var app = builder.Build(); app.MapWorkerHealthEndpoints(); await app.RunAsync(); -// Make Program class file-scoped to prevent it from being exposed to referencing assemblies -file sealed partial class Program; +// Expose Program for host wiring tests. +public sealed partial class Program; diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs index 9442dcead..db2ff8c5e 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs @@ -235,7 +235,7 @@ public sealed class DoctorJobPlugin : ISchedulerJobPlugin } // Use a default tenant for now; in production this would come from the auth context - var tenantId = "demo-prod"; + var tenantId = "default"; var summaries = await trendRepository.GetTrendSummariesAsync(tenantId, window.Value.From, window.Value.To); return Results.Ok(new { @@ -269,7 +269,7 @@ public sealed class DoctorJobPlugin : ISchedulerJobPlugin return Results.BadRequest(new { message = "Invalid time window." }); } - var tenantId = "demo-prod"; + var tenantId = "default"; var data = await trendRepository.GetTrendDataAsync(tenantId, checkId, window.Value.From, window.Value.To); return Results.Ok(new { @@ -304,7 +304,7 @@ public sealed class DoctorJobPlugin : ISchedulerJobPlugin return Results.BadRequest(new { message = "Invalid time window." }); } - var tenantId = "demo-prod"; + var tenantId = "default"; var data = await trendRepository.GetCategoryTrendDataAsync(tenantId, category, window.Value.From, window.Value.To); return Results.Ok(new { @@ -340,7 +340,7 @@ public sealed class DoctorJobPlugin : ISchedulerJobPlugin return Results.BadRequest(new { message = "Threshold must be >= 0." }); } - var tenantId = "demo-prod"; + var tenantId = "default"; var degrading = await trendRepository.GetDegradingChecksAsync(tenantId, window.Value.From, window.Value.To, effectiveThreshold); return Results.Ok(new { diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerDurableHostTests.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerDurableHostTests.cs new file mode 100644 index 000000000..e351e8f87 --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerDurableHostTests.cs @@ -0,0 +1,371 @@ +using System.Data.Common; +using System.Net.Http.Json; +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; +using StellaOps.Auth.Abstractions; +using StellaOps.Infrastructure.Postgres.Testing; +using StellaOps.Scheduler.Models; +using StellaOps.Scheduler.ImpactIndex; +using StellaOps.Scheduler.Persistence.Postgres; +using StellaOps.Scheduler.Persistence.Postgres.Repositories; +using StellaOps.Scheduler.WebService.GraphJobs; +using StellaOps.Scheduler.WebService.Auth; +using StellaOps.Scheduler.WebService.PolicyRuns; +using StellaOps.Scheduler.WebService.Runs; +using StellaOps.Scheduler.WebService.Schedules; +using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; +using Xunit; + +namespace StellaOps.Scheduler.WebService.Tests; + +[CollectionDefinition(Name)] +public sealed class SchedulerDurableHostCollection : ICollectionFixture +{ + public const string Name = "SchedulerDurableHost"; +} + +public sealed class SchedulerDurablePostgresFixture : PostgresIntegrationFixture +{ + protected override Assembly? GetMigrationAssembly() => null; + + protected override string GetModuleName() => "Scheduler"; +} + +public sealed class SchedulerDurableHostTests : IClassFixture +{ + private readonly SchedulerDurablePostgresFixture _postgres; + + public SchedulerDurableHostTests(SchedulerDurablePostgresFixture postgres) + { + _postgres = postgres; + } + + [Fact] + public async Task FreshDatabase_BootsWithStartupMigrations_AndDurableBindings() + { + using var env = SchedulerDurableEnvironmentScope.Apply(_postgres.ConnectionString); + using var factory = new SchedulerDurableWebApplicationFactory(); + using var client = factory.CreateClient(); + + var ready = await client.GetAsync("/readyz"); + ready.EnsureSuccessStatusCode(); + + using var scope = factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + + await using var connection = new NpgsqlConnection(_postgres.ConnectionString); + await connection.OpenAsync(); + + var scheduleTable = await TableExistsAsync(connection, "scheduler", "schedules"); + var runTable = await TableExistsAsync(connection, "scheduler", "runs"); + var auditTable = await TableExistsAsync(connection, "scheduler", "audit"); + + scheduleTable.Should().BeTrue("startup migrations should create scheduler.schedules"); + runTable.Should().BeTrue("startup migrations should create scheduler.runs"); + auditTable.Should().BeTrue("startup migrations should create scheduler.audit"); + } + + [Fact] + public async Task Restart_PreservesScheduleAndAuditRows() + { + const string tenantId = "tenant-durable-cutover"; + + string scheduleId; + using var env = SchedulerDurableEnvironmentScope.Apply(_postgres.ConnectionString); + using (var firstFactory = new SchedulerDurableWebApplicationFactory()) + { + using var client = firstFactory.CreateClient(); + SetHeaderAuth(client, tenantId, "scheduler.schedules.read", "scheduler.schedules.write"); + + scheduleId = await CreateScheduleAsync(client, "durable-cutover-schedule"); + + using var scope = firstFactory.Services.CreateScope(); + var scheduleRepository = scope.ServiceProvider.GetRequiredService(); + var persistedSchedule = await scheduleRepository.GetAsync(tenantId, scheduleId); + persistedSchedule.Should().NotBeNull("schedule writes should land in the durable repository"); + + await using var connection = new NpgsqlConnection(_postgres.ConnectionString); + await connection.OpenAsync(); + var auditCount = await CountAuditRowsAsync(connection, tenantId); + auditCount.Should().BeGreaterThan(0, "schedule creation should emit a persistent audit row"); + } + + using (var secondFactory = new SchedulerDurableWebApplicationFactory()) + { + using var client = secondFactory.CreateClient(); + SetHeaderAuth(client, tenantId, "scheduler.schedules.read", "scheduler.schedules.write"); + + using var scope = secondFactory.Services.CreateScope(); + var scheduleRepository = scope.ServiceProvider.GetRequiredService(); + var persistedSchedule = await scheduleRepository.GetAsync(tenantId, scheduleId); + persistedSchedule.Should().NotBeNull("data must survive a host restart against the same database"); + persistedSchedule!.Id.Should().Be(scheduleId); + + var getResponse = await client.GetAsync($"/api/v1/scheduler/schedules/{scheduleId}"); + getResponse.EnsureSuccessStatusCode(); + + var body = await getResponse.Content.ReadAsStringAsync(); + body.Should().Contain(scheduleId, "the restarted host should resolve the same persisted schedule"); + } + } + + [Fact] + public async Task Restart_PreservesResolverJobsAndReadsDurableMetrics() + { + const string tenantId = "tenant-resolver-durable"; + + string resolverJobId; + using var env = SchedulerDurableEnvironmentScope.Apply(_postgres.ConnectionString); + using (var firstFactory = new SchedulerDurableWebApplicationFactory()) + { + using var client = firstFactory.CreateClient(); + SetHeaderAuth(client, tenantId, StellaOpsScopes.SchedulerOperate, StellaOpsScopes.EffectiveWrite, StellaOpsScopes.FindingsRead); + + resolverJobId = await CreateResolverJobAsync(client, "artifact/demo-api", "policy-prod"); + + await using var connection = new NpgsqlConnection(_postgres.ConnectionString); + await connection.OpenAsync(); + var resolverRows = await CountResolverJobsAsync(connection, tenantId); + resolverRows.Should().Be(1, "resolver jobs should persist into scheduler.jobs"); + } + + using (var secondFactory = new SchedulerDurableWebApplicationFactory()) + { + using var client = secondFactory.CreateClient(); + SetHeaderAuth(client, tenantId, StellaOpsScopes.SchedulerOperate, StellaOpsScopes.EffectiveWrite, StellaOpsScopes.FindingsRead); + + var getResponse = await client.GetAsync($"/api/v1/scheduler/vuln/resolver/jobs/{resolverJobId}"); + getResponse.EnsureSuccessStatusCode(); + var jobJson = await getResponse.Content.ReadFromJsonAsync(); + + jobJson.GetProperty("id").GetString().Should().Be(resolverJobId); + jobJson.GetProperty("status").GetString().Should().Be("queued"); + jobJson.GetProperty("artifactId").GetString().Should().Be("artifact/demo-api"); + jobJson.GetProperty("policyId").GetString().Should().Be("policy-prod"); + + var metricsResponse = await client.GetAsync("/api/v1/scheduler/vuln/resolver/metrics"); + metricsResponse.EnsureSuccessStatusCode(); + var metricsJson = await metricsResponse.Content.ReadFromJsonAsync(); + var jobs = metricsJson.GetProperty("jobs"); + + jobs.GetProperty("tenantId").GetString().Should().Be(tenantId); + jobs.GetProperty("pending").GetInt32().Should().BeGreaterThanOrEqualTo(1); + jobs.GetProperty("running").GetInt32().Should().Be(0); + jobs.GetProperty("completed").GetInt32().Should().Be(0); + jobs.GetProperty("failed").GetInt32().Should().Be(0); + } + } + + private static async Task TableExistsAsync(DbConnection connection, string schemaName, string tableName) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = @schema_name + AND table_name = @table_name + ) + """; + + AddParameter(command, "schema_name", schemaName); + AddParameter(command, "table_name", tableName); + + var result = await command.ExecuteScalarAsync(); + return result is true; + } + + private static async Task CountAuditRowsAsync(DbConnection connection, string tenantId) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM scheduler.audit + WHERE tenant_id = @tenant_id + """; + + AddParameter(command, "tenant_id", tenantId); + + var result = await command.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + + private static async Task CountResolverJobsAsync(DbConnection connection, string tenantId) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM scheduler.jobs + WHERE tenant_id = @tenant_id + AND job_type = 'resolver' + """; + + AddParameter(command, "tenant_id", tenantId); + + var result = await command.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + + private static void AddParameter(DbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + private static void SetHeaderAuth(HttpClient client, string tenantId, params string[] scopes) + { + client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId); + if (scopes.Length > 0) + { + client.DefaultRequestHeaders.Add("X-Scopes", string.Join(' ', scopes)); + } + } + + private static async Task CreateScheduleAsync(HttpClient client, string name) + { + var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new + { + name, + cronExpression = "0 1 * * *", + timezone = "UTC", + mode = "analysis-only", + selection = new { scope = "all-images" } + }); + + scheduleResponse.EnsureSuccessStatusCode(); + var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync(); + var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString(); + scheduleId.Should().NotBeNullOrWhiteSpace(); + return scheduleId!; + } + + private static async Task CreateResolverJobAsync(HttpClient client, string artifactId, string policyId) + { + var response = await client.PostAsJsonAsync("/api/v1/scheduler/vuln/resolver/jobs", new + { + artifactId, + policyId, + correlationId = "resolver-proof-correlation" + }); + + response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadFromJsonAsync(); + var jobId = payload.GetProperty("id").GetString(); + jobId.Should().NotBeNullOrWhiteSpace(); + return jobId!; + } +} + +public sealed class SchedulerDurableWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new[] + { + new KeyValuePair("Scheduler:Authority:Enabled", "true"), + new KeyValuePair("Scheduler:Authority:Issuer", SchedulerJwtWebApplicationFactory.TestIssuer), + new KeyValuePair("Scheduler:Authority:Audience", SchedulerJwtWebApplicationFactory.TestAudience), + new KeyValuePair("Scheduler:Cartographer:Webhook:Enabled", "false"), + new KeyValuePair("Scheduler:Events:GraphJobs:Enabled", "false"), + new KeyValuePair("Scheduler:ImpactIndex:FixtureDirectory", GetFixtureDirectory()), + new KeyValuePair("Scheduler:Worker:Embedded", "false"), + new KeyValuePair("Scheduler:Worker:Policy:Enabled", "false") + }); + }); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(new ImpactIndexStubOptions + { + FixtureDirectory = GetFixtureDirectory(), + SnapshotId = "tests/impact-index-stub" + }); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + }).AddScheme( + StellaOpsAuthenticationDefaults.AuthenticationScheme, + static _ => { }); + }); + } + + private static string GetFixtureDirectory() + { + var assemblyLocation = typeof(SchedulerDurableWebApplicationFactory).Assembly.Location; + var assemblyDirectory = Path.GetDirectoryName(assemblyLocation) ?? AppContext.BaseDirectory; + return Path.GetFullPath(Path.Combine(assemblyDirectory, "seed-data", "impact-index")); + } +} + +public sealed class SchedulerDurableEnvironmentScope : IDisposable +{ + private readonly Dictionary _previousValues = new(StringComparer.Ordinal); + + private SchedulerDurableEnvironmentScope() + { + } + + public static SchedulerDurableEnvironmentScope Apply(string connectionString) + { + var scope = new SchedulerDurableEnvironmentScope(); + scope.Set("Scheduler__Authority__Enabled", "false"); + scope.Set("Scheduler__Cartographer__Webhook__Enabled", "false"); + scope.Set("Scheduler__Events__GraphJobs__Enabled", "false"); + scope.Set("Scheduler__Events__Webhooks__Conselier__Enabled", "true"); + scope.Set("Scheduler__Events__Webhooks__Conselier__HmacSecret", "conselier-secret"); + scope.Set("Scheduler__Events__Webhooks__Conselier__RateLimitRequests", "20"); + scope.Set("Scheduler__Events__Webhooks__Conselier__RateLimitWindowSeconds", "60"); + scope.Set("Scheduler__Events__Webhooks__Excitor__Enabled", "true"); + scope.Set("Scheduler__Events__Webhooks__Excitor__HmacSecret", "excitor-secret"); + scope.Set("Scheduler__Events__Webhooks__Excitor__RateLimitRequests", "20"); + scope.Set("Scheduler__Events__Webhooks__Excitor__RateLimitWindowSeconds", "60"); + scope.Set("Scheduler__ImpactIndex__FixtureDirectory", GetFixtureDirectory()); + scope.Set("Scheduler__Storage__ConnectionString", connectionString); + scope.Set("Scheduler__Worker__Embedded", "false"); + scope.Set("Scheduler__Worker__Policy__Api__BaseAddress", "http://localhost:5199"); + scope.Set("Scheduler__Worker__Policy__Enabled", "false"); + return scope; + } + + private static string GetFixtureDirectory() + { + var assemblyLocation = typeof(SchedulerDurableEnvironmentScope).Assembly.Location; + var assemblyDirectory = Path.GetDirectoryName(assemblyLocation) ?? AppContext.BaseDirectory; + return Path.GetFullPath(Path.Combine(assemblyDirectory, "seed-data", "impact-index")); + } + + private void Set(string name, string value) + { + _previousValues[name] = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + foreach (var pair in _previousValues) + { + Environment.SetEnvironmentVariable(pair.Key, pair.Value); + } + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerJwtWebApplicationFactory.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerJwtWebApplicationFactory.cs index 5df85a577..642aef576 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerJwtWebApplicationFactory.cs +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerJwtWebApplicationFactory.cs @@ -21,6 +21,9 @@ using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Hosting; using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Scheduler.WebService.Auth; using StellaOps.Scheduler.WebService.Options; using StellaOps.Scheduler.WebService.Runs; using StellaOps.Scheduler.ImpactIndex; @@ -51,6 +54,8 @@ public sealed class SchedulerJwtWebApplicationFactory : WebApplicationFactory { var fixtureDirectory = GetFixtureDirectory(); @@ -99,6 +104,17 @@ public sealed class SchedulerJwtWebApplicationFactory : WebApplicationFactory(); + services.RemoveAll(); + services.AddScoped(); + services.AddScoped(); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + }).AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, static _ => { }); + // Configure JWT Bearer authentication for testing. // The scheme name must match what AddStellaOpsResourceServerAuthentication // registers ("StellaOpsBearer"), NOT JwtBearerDefaults.AuthenticationScheme ("Bearer"). @@ -120,6 +136,15 @@ public sealed class SchedulerJwtWebApplicationFactory : WebApplicationFactory(options => + { + options.Authority = TestIssuer; + options.RequireHttpsMetadata = true; + options.Audiences.Clear(); + options.Audiences.Add(TestAudience); + options.RequiredScopes.Clear(); + }); + services.Configure(options => { options.Webhooks ??= new SchedulerInboundWebhooksOptions(); diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerStartupContractTests.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerStartupContractTests.cs new file mode 100644 index 000000000..4ec7c9fdb --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerStartupContractTests.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Scheduler.WebService.GraphJobs; +using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; + +namespace StellaOps.Scheduler.WebService.Tests; + +public sealed class SchedulerStartupContractTests +{ + [Fact] + public void ProductionStartup_WithoutSchedulerStorage_FailsFast() + { + using var factory = new ProductionSchedulerWebApplicationFactory(); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "Scheduler requires a configured storage connection string outside the Testing environment.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void TestingStartup_WithoutSchedulerStorage_UsesTestOnlyFallbacks() + { + using var factory = new SchedulerWebApplicationFactory(); + using var client = factory.CreateClient(); + + using var scope = factory.Services.CreateScope(); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } +} + +public sealed class ProductionSchedulerWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Production"); + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerWebApplicationFactory.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerWebApplicationFactory.cs index 8b03b28c3..509569cef 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerWebApplicationFactory.cs +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/SchedulerWebApplicationFactory.cs @@ -1,13 +1,16 @@ using System; using System.Collections.Generic; using System.IO; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using StellaOps.Auth.Abstractions; using StellaOps.Scheduler.WebService.Options; +using StellaOps.Scheduler.WebService.Auth; using StellaOps.Scheduler.WebService.Runs; using StellaOps.Scheduler.ImpactIndex; using StellaOps.Scheduler.Worker.Exceptions; @@ -18,6 +21,8 @@ public sealed class SchedulerWebApplicationFactory : WebApplicationFactory { var fixtureDirectory = GetFixtureDirectory(); @@ -59,6 +64,14 @@ public sealed class SchedulerWebApplicationFactory : WebApplicationFactory + { + options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme; + }).AddScheme( + StellaOpsAuthenticationDefaults.AuthenticationScheme, + static _ => { }); + services.Configure(options => { options.Webhooks ??= new SchedulerInboundWebhooksOptions(); diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.csproj b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.csproj index 6b8578a3c..6c031b018 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.csproj +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.csproj @@ -10,6 +10,7 @@ + @@ -18,5 +19,6 @@ + diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/TASKS.md b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/TASKS.md index 264faffdc..aeca7cd46 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/TASKS.md +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/TASKS.md @@ -1,9 +1,11 @@ # StellaOps.Scheduler.WebService.Tests Task Board This board mirrors active sprint tasks for this module. -Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`. +Source of truth: `docs/implplan/SPRINT_20260415_003_DOCS_scheduler_registry_real_backend_cutover.md`, `docs/implplan/SPRINT_20260417_019_JobEngine_truthful_webhook_rate_limiter_runtime.md`. | Task ID | Status | Notes | | --- | --- | --- | +| JOBREAL-001 | DONE | `SchedulerDurableHostTests` now prove fresh-db auto-migration, durable service bindings, and resolver-job restart-survival against PostgreSQL-backed Scheduler storage. | +| JOBREAL-WEBHOOK-001 | DONE | `WebhookRateLimiterRuntimeTests` now prove non-testing DI composition (`4/4`) and Redis-backed shared-window enforcement (`1/1`) for Scheduler inbound webhooks. | | QA-SCHED-VERIFY-002 | DONE | GraphJobs verification complete (`scheduler-graph-job-dtos` run-001 Tier 0/1/2 pass); scheduler batch closed after `scheduler-impactindex-and-surface-fs-pointers` run-001 Tier 0 `not_implemented` decision. | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scheduler/__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/StellaOps.Scheduler.Worker.Tests.csproj b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/StellaOps.Scheduler.Worker.Tests.csproj index 12eaafeb1..76d635b7d 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/StellaOps.Scheduler.Worker.Tests.csproj +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/StellaOps.Scheduler.Worker.Tests.csproj @@ -6,14 +6,18 @@ enable - + + + + + diff --git a/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/WorkerHostWiringTests.cs b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/WorkerHostWiringTests.cs new file mode 100644 index 000000000..2a95e268d --- /dev/null +++ b/src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.Worker.Tests/WorkerHostWiringTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using StellaOps.Infrastructure.Postgres.Testing; +using StellaOps.Scheduler.ImpactIndex; +using Xunit; +using System.Reflection; + +namespace StellaOps.Scheduler.Worker.Tests; + +[CollectionDefinition(Name)] +public sealed class WorkerHostWiringCollection : ICollectionFixture +{ + public const string Name = "WorkerHostWiring"; +} + +public sealed class WorkerHostPostgresFixture : PostgresIntegrationFixture +{ + protected override Assembly? GetMigrationAssembly() => null; + + protected override string GetModuleName() => "Scheduler"; +} + +public sealed class WorkerHostWiringTests : IClassFixture +{ + private readonly WorkerHostPostgresFixture _postgres; + + public WorkerHostWiringTests(WorkerHostPostgresFixture postgres) + { + _postgres = postgres; + } + + [Fact] + public async Task WorkerHost_UsesRealImpactIndex_AndDoesNotRegisterTheFixtureStub() + { + using var factory = new WorkerHostFactory(_postgres.ConnectionString); + using var client = factory.CreateClient(); + + var health = await client.GetAsync("/healthz"); + health.EnsureSuccessStatusCode(); + + using var scope = factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + scope.ServiceProvider.GetService().Should().BeNull(); + } +} + +public sealed class WorkerHostFactory : WebApplicationFactory +{ + private readonly string _connectionString; + + public WorkerHostFactory(string connectionString) + { + _connectionString = connectionString; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new[] + { + new KeyValuePair("Scheduler:Storage:ConnectionString", _connectionString) + }); + }); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + }); + } +} diff --git a/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs b/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs index fc705143b..e600243a2 100644 --- a/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs +++ b/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs @@ -195,6 +195,8 @@ public sealed class DoctorJobPlugin : ISchedulerJobPlugin // Register trend repository var connectionString = configuration["Scheduler:Storage:ConnectionString"] + ?? configuration["Scheduler:Storage:Postgres:Scheduler:ConnectionString"] + ?? configuration["Postgres:Scheduler:ConnectionString"] ?? configuration.GetConnectionString("Default") ?? ""; services.AddSingleton(sp => diff --git a/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/Endpoints/DoctorTrendEndpoints.cs b/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/Endpoints/DoctorTrendEndpoints.cs index 52c47c092..7dd99bae6 100644 --- a/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/Endpoints/DoctorTrendEndpoints.cs +++ b/src/JobEngine/StellaOps.Scheduler.plugins/StellaOps.Scheduler.Plugin.Doctor/Endpoints/DoctorTrendEndpoints.cs @@ -176,6 +176,6 @@ public static class DoctorTrendEndpoints return tenantHeader.ToString(); } - return "demo-prod"; + return "default"; } } diff --git a/src/Registry/StellaOps.Registry.TokenService/Admin/IPlanRuleStore.cs b/src/Registry/StellaOps.Registry.TokenService/Admin/IPlanRuleStore.cs index 0af4c3e4f..77ab679dd 100644 --- a/src/Registry/StellaOps.Registry.TokenService/Admin/IPlanRuleStore.cs +++ b/src/Registry/StellaOps.Registry.TokenService/Admin/IPlanRuleStore.cs @@ -51,6 +51,11 @@ public interface IPlanRuleStore int page = 1, int pageSize = 50, CancellationToken cancellationToken = default); + + /// + /// Gets the total number of audit history entries for the specified plan. + /// + Task GetAuditHistoryCountAsync(string? planId = null, CancellationToken cancellationToken = default); } /// diff --git a/src/Registry/StellaOps.Registry.TokenService/Admin/InMemoryPlanRuleStore.cs b/src/Registry/StellaOps.Registry.TokenService/Admin/InMemoryPlanRuleStore.cs index 12a4b9bf2..0e6f7d51b 100644 --- a/src/Registry/StellaOps.Registry.TokenService/Admin/InMemoryPlanRuleStore.cs +++ b/src/Registry/StellaOps.Registry.TokenService/Admin/InMemoryPlanRuleStore.cs @@ -207,6 +207,18 @@ public sealed class InMemoryPlanRuleStore : IPlanRuleStore return Task.FromResult>(result); } + public Task 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); diff --git a/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/PostgresPlanRuleStore.cs b/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/PostgresPlanRuleStore.cs new file mode 100644 index 000000000..07a26b97f --- /dev/null +++ b/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/PostgresPlanRuleStore.cs @@ -0,0 +1,613 @@ +// +// Copyright (c) Stella Operations. Licensed under BUSL-1.1. +// + +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; + +/// +/// PostgreSQL-backed implementation of . +/// +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 _logger; + + public PostgresPlanRuleStore( + RegistryTokenPlanRuleDataSource dataSource, + TimeProvider timeProvider, + ILogger 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> 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(); + 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 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 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 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 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 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> 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(); + 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 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 repositories, + IReadOnlyList 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 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 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(reader.GetFieldValue(reader.GetOrdinal("repositories"))); + var allowlist = DeserializeList(reader.GetFieldValue(reader.GetOrdinal("allowlist"))); + var rateLimit = reader.IsDBNull(reader.GetOrdinal("rate_limit")) + ? null + : JsonSerializer.Deserialize(reader.GetFieldValue(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(reader.GetOrdinal("created_at")), + ModifiedAt = reader.GetFieldValue(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(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 DeserializeList(string json) + => JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + + private static string BuildUpdateSummary(PlanRuleDto existing, PlanRuleDto updated) + { + var changes = new List(); + + 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)}"; + } +} diff --git a/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPersistenceExtensions.cs b/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPersistenceExtensions.cs new file mode 100644 index 000000000..c6b0cb766 --- /dev/null +++ b/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPersistenceExtensions.cs @@ -0,0 +1,66 @@ +// +// Copyright (c) Stella Operations. Licensed under BUSL-1.1. +// + +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; + +/// +/// Service registration helpers for registry token persistence. +/// +public static class RegistryTokenPersistenceExtensions +{ + private const string PostgresSectionName = $"{RegistryTokenServiceOptions.SectionName}:Postgres"; + + /// + /// Registers the PostgreSQL-backed plan rule store and startup migrations. + /// + public static IServiceCollection AddRegistryTokenPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var postgresSection = configuration.GetSection(PostgresSectionName); + var schemaName = postgresSection.GetValue(nameof(PostgresOptions.SchemaName)); + if (string.IsNullOrWhiteSpace(schemaName)) + { + schemaName = RegistryTokenPlanRuleDataSource.DefaultSchemaName; + } + else + { + schemaName = schemaName.Trim(); + } + + services.AddPostgresOptions(configuration, PostgresSectionName); + services.PostConfigure(options => + { + options.SchemaName = string.IsNullOrWhiteSpace(options.SchemaName) + ? schemaName + : options.SchemaName.Trim(); + }); + + services.AddStartupMigrations( + schemaName, + "Registry.TokenService", + typeof(RegistryTokenPersistenceExtensions).Assembly, + options => options.ConnectionString); + + services.AddSingleton(sp => + new RegistryTokenPlanRuleDataSource( + sp.GetRequiredService>().Value, + sp.GetRequiredService>())); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPlanRuleDataSource.cs b/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPlanRuleDataSource.cs new file mode 100644 index 000000000..ec65b57b2 --- /dev/null +++ b/src/Registry/StellaOps.Registry.TokenService/Admin/Persistence/RegistryTokenPlanRuleDataSource.cs @@ -0,0 +1,53 @@ +// +// Copyright (c) Stella Operations. Licensed under BUSL-1.1. +// + +using Microsoft.Extensions.Logging; +using StellaOps.Infrastructure.Postgres.Connections; +using StellaOps.Infrastructure.Postgres.Options; + +namespace StellaOps.Registry.TokenService.Admin.Persistence; + +/// +/// PostgreSQL data source for registry token plan rules. +/// +public sealed class RegistryTokenPlanRuleDataSource : DataSourceBase +{ + /// + /// Default PostgreSQL schema used by the token service. + /// + public const string DefaultSchemaName = "registry_token"; + + /// + /// Creates a new data source for registry token persistence. + /// + public RegistryTokenPlanRuleDataSource( + PostgresOptions options, + ILogger 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, + }; + } +} diff --git a/src/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs b/src/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs index 591ee84e7..16bb80a91 100644 --- a/src/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs +++ b/src/Registry/StellaOps.Registry.TokenService/Admin/PlanAdminEndpoints.cs @@ -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 { Items = entries, - TotalCount = entries.Count, // Note: in-memory store doesn't track total; real store should + TotalCount = totalCount, Page = page, PageSize = pageSize, }; diff --git a/src/Registry/StellaOps.Registry.TokenService/Migrations/001_initial_schema.sql b/src/Registry/StellaOps.Registry.TokenService/Migrations/001_initial_schema.sql new file mode 100644 index 000000000..b16600b92 --- /dev/null +++ b/src/Registry/StellaOps.Registry.TokenService/Migrations/001_initial_schema.sql @@ -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); diff --git a/src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs b/src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs index a5f3a6ed7..224768ea1 100644 --- a/src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs +++ b/src/Registry/StellaOps.Registry.TokenService/PlanRegistry.cs @@ -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; /// public sealed class PlanRegistry { + private readonly IPlanRuleStore? _planRuleStore; private readonly IReadOnlyDictionary _plans; private readonly IReadOnlySet _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 AuthorizeAsync( ClaimsPrincipal principal, - IReadOnlyList requests) + IReadOnlyList 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 requests) + { + return AuthorizeAsync(principal, requests).GetAwaiter().GetResult(); + } + + private async Task 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 _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(rule.Actions, StringComparer.OrdinalIgnoreCase); } + public RepositoryDescriptor(RepositoryRuleDto rule) + { + Pattern = rule.Pattern; + _pattern = Compile(rule.Pattern); + _allowedActions = new HashSet(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); } diff --git a/src/Registry/StellaOps.Registry.TokenService/Program.cs b/src/Registry/StellaOps.Registry.TokenService/Program.cs index 051f10df5..8cc55c022 100644 --- a/src/Registry/StellaOps.Registry.TokenService/Program.cs +++ b/src/Registry/StellaOps.Registry.TokenService/Program.cs @@ -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.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() .Bind(builder.Configuration.GetSection(RegistryTokenServiceOptions.SectionName)) - .PostConfigure(options => options.Validate()) + .PostConfigure(options => options.Validate(requireStaticPlans)) .ValidateOnStart(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - return new PlanRegistry(options); -}); builder.Services.AddSingleton(); -// Plan Admin API dependencies -builder.Services.AddSingleton(); +if (!string.IsNullOrWhiteSpace(registryPostgresConnectionString)) +{ + builder.Services.AddRegistryTokenPersistence(builder.Configuration); +} +else if (builder.Environment.IsEnvironment("Testing")) +{ + builder.Services.AddSingleton(); +} +else +{ + throw new InvalidOperationException( + $"Missing durable plan-rule persistence configuration. Set {RegistryTokenServiceOptions.SectionName}:Postgres:ConnectionString."); +} + +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + return new PlanRegistry(options, sp.GetRequiredService()); +}); + builder.Services.AddSingleton(); 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 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 { diff --git a/src/Registry/StellaOps.Registry.TokenService/RegistryTokenIssuer.cs b/src/Registry/StellaOps.Registry.TokenService/RegistryTokenIssuer.cs index 6b053044c..aeb1c9098 100644 --- a/src/Registry/StellaOps.Registry.TokenService/RegistryTokenIssuer.cs +++ b/src/Registry/StellaOps.Registry.TokenService/RegistryTokenIssuer.cs @@ -42,12 +42,13 @@ public sealed class RegistryTokenIssuer _signingCredentials = SigningKeyLoader.Load(_options.Signing); } - public RegistryTokenResponse IssueToken( + public async Task IssueTokenAsync( ClaimsPrincipal principal, string service, - IReadOnlyList requests) + IReadOnlyList 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("reason", decision.FailureReason ?? "denied")); @@ -93,6 +94,12 @@ public sealed class RegistryTokenIssuer now); } + public RegistryTokenResponse IssueToken( + ClaimsPrincipal principal, + string service, + IReadOnlyList requests) + => IssueTokenAsync(principal, service, requests).GetAwaiter().GetResult(); + private static object BuildAccessClaim(IReadOnlyList requests) { return requests diff --git a/src/Registry/StellaOps.Registry.TokenService/RegistryTokenServiceOptions.cs b/src/Registry/StellaOps.Registry.TokenService/RegistryTokenServiceOptions.cs index ec9f90dcd..2f00d6beb 100644 --- a/src/Registry/StellaOps.Registry.TokenService/RegistryTokenServiceOptions.cs +++ b/src/Registry/StellaOps.Registry.TokenService/RegistryTokenServiceOptions.cs @@ -42,7 +42,7 @@ public sealed class RegistryTokenServiceOptions /// 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) diff --git a/src/Registry/StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj b/src/Registry/StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj index a26677bb0..9b5b781b2 100644 --- a/src/Registry/StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj +++ b/src/Registry/StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj @@ -9,6 +9,7 @@ + @@ -20,11 +21,13 @@ + + 1.0.0-alpha1 diff --git a/src/Registry/StellaOps.Registry.TokenService/TASKS.md b/src/Registry/StellaOps.Registry.TokenService/TASKS.md index 78fe6d3e1..7cc7cde95 100644 --- a/src/Registry/StellaOps.Registry.TokenService/TASKS.md +++ b/src/Registry/StellaOps.Registry.TokenService/TASKS.md @@ -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. | diff --git a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/PlanAdminEndpointsTests.cs b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/PlanAdminEndpointsTests.cs index 66da46235..42afd8bf2 100644 --- a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/PlanAdminEndpointsTests.cs +++ b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/PlanAdminEndpointsTests.cs @@ -321,7 +321,7 @@ public sealed class PlanAdminEndpointsTests : IClassFixture +{ + 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(() => + store.CreateAsync(new CreatePlanRequest { Name = "duplicate-plan" }, "bob")); + } + + private RegistryTokenPlanRuleDataSource CreateDataSource() + => new(_fixture.Fixture.CreateOptions(), NullLogger.Instance); + + private static PostgresPlanRuleStore CreateStore( + RegistryTokenPlanRuleDataSource dataSource, + TimeProvider timeProvider) + => new(dataSource, timeProvider, NullLogger.Instance); + + private async Task 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"; + } +} diff --git a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/RegistryTokenPersistenceTests.cs b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/RegistryTokenPersistenceTests.cs new file mode 100644 index 000000000..196e6d8f8 --- /dev/null +++ b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/Admin/RegistryTokenPersistenceTests.cs @@ -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 + { + [$"{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(); + Assert.IsType(store); + + var options = provider.GetRequiredService>().Value; + Assert.Equal("registry_token_custom", options.SchemaName); + + Assert.Contains( + services, + descriptor => descriptor.ServiceType == typeof(IHostedService)); + } +} diff --git a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/PlanRegistryTests.cs b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/PlanRegistryTests.cs index e70a2488b..1250f010e 100644 --- a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/PlanRegistryTests.cs +++ b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/PlanRegistryTests.cs @@ -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); + } } diff --git a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/RegistryTokenDurableRuntimeTests.cs b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/RegistryTokenDurableRuntimeTests.cs new file mode 100644 index 000000000..037225db2 --- /dev/null +++ b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/RegistryTokenDurableRuntimeTests.cs @@ -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 +{ + 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(); + 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 + { + 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("Test", _ => { }); + + services.PostConfigure(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 + { + public DurableTestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.Authorization.Any()) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var claims = new List + { + 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(); + } +} diff --git a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/StellaOps.Registry.TokenService.Tests.csproj b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/StellaOps.Registry.TokenService.Tests.csproj index e4adb3cff..7f14b264a 100644 --- a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/StellaOps.Registry.TokenService.Tests.csproj +++ b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/StellaOps.Registry.TokenService.Tests.csproj @@ -24,6 +24,7 @@ + - \ No newline at end of file + diff --git a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/TASKS.md b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/TASKS.md index c8023fe3d..12a6b1857 100644 --- a/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/TASKS.md +++ b/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/TASKS.md @@ -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. |