diff --git a/etc/authority.plugins/standard.yaml b/etc/authority.plugins/standard.yaml index 4fb213798..b3ca56107 100644 --- a/etc/authority.plugins/standard.yaml +++ b/etc/authority.plugins/standard.yaml @@ -1,7 +1,7 @@ # Standard plugin configuration (Mongo-backed identity store). -bootstrapUser: - username: "admin" - password: "Admin@Stella2026!" +# Fresh installs seed first-party clients only. Create the first human admin +# through the setup wizard or another explicit manual bootstrap flow. +tenantId: "default" passwordPolicy: minimumLength: 12 diff --git a/etc/issuer-directory.yaml.sample b/etc/issuer-directory.yaml.sample index 0409a9b1a..7f8817856 100644 --- a/etc/issuer-directory.yaml.sample +++ b/etc/issuer-directory.yaml.sample @@ -1,5 +1,5 @@ IssuerDirectory: - # Override connection secrets via environment variables (ISSUERDIRECTORY__MONGO__*) + # Override connection secrets via environment variables (ISSUERDIRECTORY__PERSISTENCE__*) # rather than editing this file for production. telemetry: minimumLogLevel: Information @@ -15,7 +15,7 @@ IssuerDirectory: tenantHeader: X-StellaOps-Tenant seedCsafPublishers: true csafSeedPath: data/csaf-publishers.json - Postgres: - connectionString: "Host=postgres;Port=5432;Database=stellaops_platform;Username=stellaops;Password=change-me" - schema: issuer - commandTimeoutSeconds: 30 + persistence: + provider: Postgres + postgresConnectionString: "Host=postgres;Port=5432;Database=stellaops_platform;Username=stellaops;Password=change-me" + schemaName: issuer diff --git a/etc/issuer-directory/issuer-directory.yaml b/etc/issuer-directory/issuer-directory.yaml index 618d056b1..5cb741c53 100644 --- a/etc/issuer-directory/issuer-directory.yaml +++ b/etc/issuer-directory/issuer-directory.yaml @@ -1,5 +1,5 @@ IssuerDirectory: - # Override connection secrets via environment variables (ISSUERDIRECTORY__MONGO__*) + # Override connection secrets via environment variables (ISSUERDIRECTORY__PERSISTENCE__*) # rather than editing this file for production. telemetry: minimumLogLevel: Information @@ -15,7 +15,7 @@ IssuerDirectory: tenantHeader: X-StellaOps-Tenant seedCsafPublishers: true csafSeedPath: data/csaf-publishers.json - Postgres: - connectionString: "Host=db.stella-ops.local;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops" - schema: issuer - commandTimeoutSeconds: 30 + persistence: + provider: Postgres + postgresConnectionString: "Host=db.stella-ops.local;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops" + schemaName: issuer diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/TASKS.md b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/TASKS.md index 190ecc00b..3ff5c60ac 100644 --- a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/TASKS.md +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/TASKS.md @@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0375-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Infrastructure. | | AUDIT-0375-A | TODO | Pending approval (revalidated 2026-01-07). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| ISSUERDIR-REAL-001 | DONE | 2026-04-16: in-memory repositories remain available for testing-only IssuerDirectory runtime; non-testing host defaults no longer use them. | diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/Persistence/IssuerDirectoryPersistenceRuntimeTests.cs b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/Persistence/IssuerDirectoryPersistenceRuntimeTests.cs new file mode 100644 index 000000000..bf82b973c --- /dev/null +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/Persistence/IssuerDirectoryPersistenceRuntimeTests.cs @@ -0,0 +1,137 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using StellaOps.IssuerDirectory.Core.Abstractions; +using StellaOps.IssuerDirectory.WebService.Options; +using StellaOps.IssuerDirectory.WebService.Persistence; + +namespace StellaOps.IssuerDirectory.WebService.Tests.Persistence; + +public sealed class IssuerDirectoryPersistenceRuntimeTests +{ + [Fact] + public void Testing_InMemoryProvider_RegistersInMemoryRepositories() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + var options = CreateOptions(); + options.Persistence.Provider = "InMemory"; + + services.AddIssuerDirectoryPersistenceRuntime( + configuration, + new TestHostEnvironment("Testing"), + options); + + services.Single(descriptor => descriptor.ServiceType == typeof(IIssuerRepository)) + .ImplementationType! + .Name + .Should() + .Be("InMemoryIssuerRepository"); + services.Single(descriptor => descriptor.ServiceType == typeof(IIssuerKeyRepository)) + .ImplementationType! + .Name + .Should() + .Be("InMemoryIssuerKeyRepository"); + services.Single(descriptor => descriptor.ServiceType == typeof(IIssuerTrustRepository)) + .ImplementationType! + .Name + .Should() + .Be("InMemoryIssuerTrustRepository"); + } + + [Fact] + public void Production_InMemoryProvider_FailsFast() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + var options = CreateOptions(); + options.Persistence.Provider = "InMemory"; + + var action = () => services.AddIssuerDirectoryPersistenceRuntime( + configuration, + new TestHostEnvironment("Production"), + options); + + action.Should() + .Throw() + .WithMessage("*Persistence:Provider=Postgres*outside Testing*"); + } + + [Fact] + public void Production_DefaultsToPostgresAndRegistersDurableRepositories() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + var options = CreateOptions(); + + services.AddIssuerDirectoryPersistenceRuntime( + configuration, + new TestHostEnvironment("Production"), + options); + + options.GetEffectivePersistenceProvider().Should().Be("Postgres"); + options.GetEffectivePostgresConnectionString().Should().Be("Host=issuer-db;Port=5432;Database=issuer;Username=issuer;Password=issuer"); + services.Single(descriptor => descriptor.ServiceType == typeof(IIssuerRepository)) + .ImplementationType! + .Name + .Should() + .Be("PostgresIssuerRepository"); + services.Single(descriptor => descriptor.ServiceType == typeof(IIssuerKeyRepository)) + .ImplementationType! + .Name + .Should() + .Be("PostgresIssuerKeyRepository"); + services.Single(descriptor => descriptor.ServiceType == typeof(IIssuerTrustRepository)) + .ImplementationType! + .Name + .Should() + .Be("PostgresIssuerTrustRepository"); + } + + [Fact] + public void LegacyPostgresConfiguration_RemainsValid() + { + var options = CreateOptions(); + options.Authority.Enabled = false; + options.Persistence.Provider = string.Empty; + + var action = () => options.Validate(); + + action.Should().NotThrow(); + options.GetEffectivePersistenceProvider().Should().Be("Postgres"); + options.GetEffectivePostgresSchema().Should().Be("issuer"); + } + + private static IssuerDirectoryWebServiceOptions CreateOptions() + { + return new IssuerDirectoryWebServiceOptions + { + Authority = + { + Enabled = true, + Issuer = "https://authority.stella-ops.local", + Audiences = { "stellaops-platform" } + }, + Postgres = + { + ConnectionString = "Host=issuer-db;Port=5432;Database=issuer;Username=issuer;Password=issuer", + Schema = "issuer" + } + }; + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public TestHostEnvironment(string environmentName) + { + EnvironmentName = environmentName; + } + + public string EnvironmentName { get; set; } + public string ApplicationName { get; set; } = "IssuerDirectoryPersistenceRuntimeTests"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/StellaOps.IssuerDirectory.WebService.Tests.csproj b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/StellaOps.IssuerDirectory.WebService.Tests.csproj new file mode 100644 index 000000000..4c6690da3 --- /dev/null +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/StellaOps.IssuerDirectory.WebService.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + preview + false + enable + enable + + + + + + + + + + + diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/TASKS.md b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/TASKS.md new file mode 100644 index 000000000..113a8db8c --- /dev/null +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService.Tests/TASKS.md @@ -0,0 +1,8 @@ +# StellaOps.IssuerDirectory.WebService.Tests Task Board + +This board mirrors active sprint tasks for this module. +Source of truth: `docs/implplan/SPRINT_20260416_013_Authority_issuerdirectory_truthful_persistence_runtime.md`. + +| Task ID | Status | Notes | +| --- | --- | --- | +| ISSUERDIR-REAL-002 | DONE | 2026-04-16: focused runtime proof for IssuerDirectory persistence selection passed (`4/4`). | diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Options/IssuerDirectoryWebServiceOptions.cs b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Options/IssuerDirectoryWebServiceOptions.cs index 7f77647e0..5fb903312 100644 --- a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Options/IssuerDirectoryWebServiceOptions.cs +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Options/IssuerDirectoryWebServiceOptions.cs @@ -1,4 +1,5 @@ using StellaOps.Auth.Abstractions; +using StellaOps.IssuerDirectory.Persistence.Postgres; namespace StellaOps.IssuerDirectory.WebService.Options; @@ -12,6 +13,8 @@ public sealed class IssuerDirectoryWebServiceOptions public PersistenceOptions Persistence { get; set; } = new(); + public LegacyPostgresOptions Postgres { get; set; } = new(); + public string TenantHeader { get; set; } = "X-StellaOps-Tenant"; public bool SeedCsafPublishers { get; set; } = true; @@ -26,7 +29,52 @@ public sealed class IssuerDirectoryWebServiceOptions } Authority.Validate(); - Persistence.Validate(); + + var provider = GetEffectivePersistenceProvider().Trim().ToLowerInvariant(); + if (provider != "inmemory" && provider != "postgres") + { + throw new InvalidOperationException($"IssuerDirectory persistence provider '{GetEffectivePersistenceProvider()}' is not supported. Use 'InMemory' or 'Postgres'."); + } + + if (provider == "postgres" && string.IsNullOrWhiteSpace(GetEffectivePostgresConnectionString())) + { + throw new InvalidOperationException("PostgreSQL connection string is required when persistence provider is 'Postgres'."); + } + } + + public string GetEffectivePersistenceProvider() + { + var provider = Persistence.Provider?.Trim(); + return string.IsNullOrWhiteSpace(provider) + ? "Postgres" + : provider; + } + + public string GetEffectivePostgresConnectionString() + { + if (!string.IsNullOrWhiteSpace(Persistence.PostgresConnectionString)) + { + return Persistence.PostgresConnectionString.Trim(); + } + + return string.IsNullOrWhiteSpace(Postgres.ConnectionString) + ? string.Empty + : Postgres.ConnectionString.Trim(); + } + + public string GetEffectivePostgresSchema() + { + if (!string.IsNullOrWhiteSpace(Persistence.SchemaName)) + { + return Persistence.SchemaName.Trim(); + } + + if (!string.IsNullOrWhiteSpace(Postgres.Schema)) + { + return Postgres.Schema.Trim(); + } + + return IssuerDirectoryDataSource.DefaultSchemaName; } public sealed class TelemetryOptions @@ -81,27 +129,24 @@ public sealed class IssuerDirectoryWebServiceOptions public sealed class PersistenceOptions { /// - /// Storage provider for IssuerDirectory. Valid values: "InMemory", "Postgres". + /// Storage provider for IssuerDirectory. Valid values: "InMemory" (testing only), "Postgres". /// - public string Provider { get; set; } = "InMemory"; + public string Provider { get; set; } = "Postgres"; /// /// PostgreSQL connection string. Required when Provider is "Postgres". /// public string PostgresConnectionString { get; set; } = string.Empty; - public void Validate() - { - var normalized = Provider?.Trim().ToLowerInvariant() ?? string.Empty; - if (normalized != "inmemory" && normalized != "postgres") - { - throw new InvalidOperationException($"IssuerDirectory persistence provider '{Provider}' is not supported. Use 'InMemory' or 'Postgres'."); - } + public string SchemaName { get; set; } = IssuerDirectoryDataSource.DefaultSchemaName; + } - if (normalized == "postgres" && string.IsNullOrWhiteSpace(PostgresConnectionString)) - { - throw new InvalidOperationException("PostgreSQL connection string is required when persistence provider is 'Postgres'."); - } - } + public sealed class LegacyPostgresOptions + { + public string ConnectionString { get; set; } = string.Empty; + + public string Schema { get; set; } = IssuerDirectoryDataSource.DefaultSchemaName; + + public int CommandTimeoutSeconds { get; set; } = 30; } } diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Persistence/IssuerDirectoryPersistenceRuntime.cs b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Persistence/IssuerDirectoryPersistenceRuntime.cs new file mode 100644 index 000000000..0545b17d2 --- /dev/null +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Persistence/IssuerDirectoryPersistenceRuntime.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StellaOps.Infrastructure.Postgres.Migrations; +using StellaOps.IssuerDirectory.Infrastructure; +using StellaOps.IssuerDirectory.Persistence.Extensions; +using StellaOps.IssuerDirectory.Persistence.Postgres; +using StellaOps.IssuerDirectory.WebService.Options; + +namespace StellaOps.IssuerDirectory.WebService.Persistence; + +public static class IssuerDirectoryPersistenceRuntime +{ + public static void AddIssuerDirectoryPersistenceRuntime( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment, + IssuerDirectoryWebServiceOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(options); + + var provider = options.GetEffectivePersistenceProvider().Trim().ToLowerInvariant(); + + if (provider == "postgres") + { + services.AddIssuerDirectoryPersistence(postgres => + { + postgres.ConnectionString = options.GetEffectivePostgresConnectionString(); + postgres.SchemaName = options.GetEffectivePostgresSchema(); + }); + + services.AddStartupMigrations( + schemaName: options.GetEffectivePostgresSchema(), + moduleName: "IssuerDirectory.Persistence", + migrationsAssembly: typeof(IssuerDirectoryDataSource).Assembly, + connectionStringSelector: static configured => configured.GetEffectivePostgresConnectionString()); + + return; + } + + if (provider == "inmemory" && environment.IsEnvironment("Testing")) + { + services.AddIssuerDirectoryInfrastructure(configuration); + return; + } + + throw new InvalidOperationException( + "IssuerDirectory requires Persistence:Provider=Postgres outside Testing."); + } +} diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs index 15024d146..fe7e0ee2a 100644 --- a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/Program.cs @@ -23,6 +23,7 @@ using StellaOps.IssuerDirectory.Persistence.Extensions; using StellaOps.IssuerDirectory.Persistence.Postgres; using StellaOps.IssuerDirectory.WebService.Endpoints; using StellaOps.IssuerDirectory.WebService.Options; +using StellaOps.IssuerDirectory.WebService.Persistence; using StellaOps.IssuerDirectory.WebService.Security; using StellaOps.IssuerDirectory.WebService.Services; using StellaOps.Router.AspNet; @@ -151,26 +152,22 @@ static void ConfigurePersistence( WebApplicationBuilder builder, IssuerDirectoryWebServiceOptions options) { - var provider = options.Persistence.Provider?.Trim().ToLowerInvariant() ?? "postgres"; + var provider = options.GetEffectivePersistenceProvider().Trim().ToLowerInvariant(); if (provider == "postgres") { Log.Information("Using PostgreSQL persistence for IssuerDirectory."); - builder.Services.AddIssuerDirectoryPersistence(opts => - { - opts.ConnectionString = options.Persistence.PostgresConnectionString; - opts.SchemaName = "issuer"; - }); - builder.Services.AddStartupMigrations( - schemaName: IssuerDirectoryDataSource.DefaultSchemaName, - moduleName: "IssuerDirectory.Persistence", - migrationsAssembly: typeof(IssuerDirectoryDataSource).Assembly, - connectionStringSelector: static configured => configured.Persistence.PostgresConnectionString); + builder.Services.AddIssuerDirectoryPersistenceRuntime(builder.Configuration, builder.Environment, options); + } + else if (provider == "inmemory" && builder.Environment.IsEnvironment("Testing")) + { + Log.Information("Using in-memory persistence for IssuerDirectory in Testing."); + builder.Services.AddIssuerDirectoryPersistenceRuntime(builder.Configuration, builder.Environment, options); } else { - Log.Information("Using in-memory persistence for IssuerDirectory (non-production)."); - builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration); + throw new InvalidOperationException( + "IssuerDirectory requires Persistence:Provider=Postgres outside Testing."); } } diff --git a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/TASKS.md b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/TASKS.md index 2b40a1952..c48fb0773 100644 --- a/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/TASKS.md +++ b/src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/TASKS.md @@ -9,3 +9,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0378-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.WebService. | | AUDIT-0378-A | TODO | Revalidated 2026-01-07 (open findings). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| ISSUERDIR-REAL-001 | DONE | 2026-04-16: non-testing IssuerDirectory web runtime now requires PostgreSQL persistence and no longer silently binds in-memory repositories. | +| ISSUERDIR-REAL-002 | DONE | 2026-04-16: added focused `IssuerDirectoryPersistenceRuntimeTests` coverage for testing/in-memory, production/Postgres, and fail-fast behavior. | +| ISSUERDIR-REAL-003 | DONE | 2026-04-16: synced IssuerDirectory docs, config examples, and task boards to the truthful PostgreSQL runtime boundary. |