46cb6965ac
Security-sensitive batch, handled main-thread for careful judgment on secret-leak and pepper-bypass paths. Secret leak / pepper bypass: - CD-016 (pepper bypass): InboundApiRepository's GetApiKeyByValueAsync no longer hashes the candidate with the unpeppered ApiKeyHasher.Default — ctor takes a lazy Func<IApiKeyHasher> accessor (lazy so test composition roots without a pepper still bring up the repository), and the DI registration wires sp.GetService<IApiKeyHasher>() so the production peppered hasher matches the stored KeyHash. Regression test asserts positive (peppered roundtrip) AND negative (Default hasher misses the same key — proving the lookup uses the injected hasher). - MgmtSvc-020 (SMTP credential leak): UpdateSmtpConfig/ListSmtpConfigs now project through SmtpConfigPublicShape so the response payload and audit-row afterState never carry the Credentials field — only a HasCredentials bool. The SMTP password / OAuth2 client secret no longer leaves the Admin-only UpdateSmtpConfig boundary the caller already supplied it to. Redaction: - AuditLog-008 (test-fixture under-redact): new SafeDefaultAuditPayloadFilter (stateless singleton) does HTTP header redaction for the always-sensitive defaults (Authorization, X-Api-Key, Cookie, Set-Cookie). FallbackAuditWriter, CentralAuditWriter, and AuditLogIngestActor (both ingest paths) default to it instead of null — composition roots that bypass AddAuditLog can no longer write unredacted auth headers to the audit store. - NotifService-025 (over-mask): CredentialRedactor.Scrub now only masks the last colon-separated component (password / clientSecret) AND only if it's >= 12 chars (typical password heuristic). Short user names like "root" no longer become global redaction tokens that eat unrelated diagnostic text. The full packed string is always masked regardless of length. 3 new negative tests pin the no-over-mask contract. Audit-row correctness / fail-loud: - InboundAPI-025: Program.cs UseWhen predicate now excludes /api/audit, /api/management, /api/centralui, /api/script-analysis AND requires POST — the AuditWriteMiddleware no longer emits spurious ApiInbound rows for audit-log query/export endpoints (write-on-read recursion broken). - ESG-021: ApplyAuth now logs Warning (not silent) on empty AuthConfiguration for apikey/basic, unknown AuthType, and malformed Basic config. AuthConfiguration value NEVER logged. AuthType=none remains silent (documented unauthenticated sentinel). - Security-021: AddSecurity now logs a startup Warning when RequireHttpsCookie=false — an HTTP-only deployment that previously transmitted the cookie-embedded JWT silently in cleartext is now audible in the log. Defensive: - CD-021: SwitchOutPartitionAsync's monthBoundary format string now yyyy-MM-dd HH:mm:ss.fffffff (datetime2(7) precision) so a future sub-second / non-midnight boundary doesn't silently round to the wrong partition. Plus reconciled stale per-module Open-findings counters that had drifted from earlier sessions (AuditLog, CD, ESG, IAPI, MgmtSvc, NotifService, Security). Build clean; all affected test projects green (Host 208, ConfigDB 242, ESG 69, IAPI 151, MgmtSvc 100, NotifService 55, Security 85, AuditLog 247/248 — 1 pre-existing date-sensitive integration test flake on PartitionPurgeTests, unrelated). README regenerated: 46 open (was 54).
109 lines
6.2 KiB
C#
109 lines
6.2 KiB
C#
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using ScadaLink.Commons.Interfaces;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Interfaces.Transport;
|
|
using ScadaLink.ConfigurationDatabase.Maintenance;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.ConfigurationDatabase.Services;
|
|
|
|
namespace ScadaLink.ConfigurationDatabase;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// Registers the ScadaLinkDbContext with the provided SQL Server connection string.
|
|
/// </summary>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <param name="connectionString">SQL Server connection string for the central configuration database.</param>
|
|
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
|
|
{
|
|
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
|
|
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
|
|
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
|
|
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
|
|
// CreateProtector during model building.
|
|
services.AddDbContext<ScadaLinkDbContext>((serviceProvider, options) =>
|
|
{
|
|
options.UseSqlServer(connectionString)
|
|
.ConfigureWarnings(w => w.Ignore(
|
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
|
});
|
|
|
|
// AddDbContext registers ScadaLinkDbContext via EF's activator, which only injects
|
|
// DbContextOptions. Override that registration (last registration wins for resolution)
|
|
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
|
|
// value converter for secret columns is always wired up at runtime.
|
|
services.AddScoped(serviceProvider =>
|
|
{
|
|
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaLinkDbContext>>();
|
|
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
|
|
return new ScadaLinkDbContext(options, protectionProvider);
|
|
});
|
|
|
|
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
|
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
|
|
// CD-016: factory registration wires a lazy accessor for IApiKeyHasher so
|
|
// the production peppered hasher is used (via DI) when GetApiKeyByValueAsync
|
|
// is actually called, but composition roots that never call it (and may
|
|
// not register IApiKeyHasher at all) still bring up the repository.
|
|
services.AddScoped<IInboundApiRepository>(sp => new InboundApiRepository(
|
|
sp.GetRequiredService<ScadaLinkDbContext>(),
|
|
hasherAccessor: () => sp.GetService<Commons.Types.InboundApi.IApiKeyHasher>()
|
|
?? Commons.Types.InboundApi.ApiKeyHasher.Default,
|
|
logger: sp.GetService<ILogger<InboundApiRepository>>()));
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
|
|
|
// #23 M6 Bundle D: IPartitionMaintenance drives the daily roll-forward
|
|
// of pf_AuditLog_Month from the central AuditLogPartitionMaintenanceService
|
|
// hosted service. Scoped because the implementation reuses the per-scope
|
|
// ScadaLinkDbContext for raw-SQL execution; the hosted service opens a
|
|
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
|
|
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
|
|
|
|
services.AddDataProtection()
|
|
.PersistKeysToDbContext<ScadaLinkDbContext>();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Obsolete parameterless overload. This previously registered nothing, which meant a
|
|
/// central node wired up with it failed late and opaquely — the first repository
|
|
/// resolution threw a DI exception far from the actual misconfiguration. Use
|
|
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
|
|
/// configured connection string.
|
|
/// </summary>
|
|
/// <param name="services">The service collection (unused; this overload always throws).</param>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Always thrown. The connection string is required; there is no valid no-op registration.
|
|
/// </exception>
|
|
[Obsolete(
|
|
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
|
|
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
|
|
error: true)]
|
|
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
|
|
{
|
|
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
|
|
// fail fast at wire-up time rather than silently registering nothing and surfacing
|
|
// an opaque DI resolution failure much later.
|
|
throw new InvalidOperationException(
|
|
"AddConfigurationDatabase() requires a connection string. Call " +
|
|
"AddConfigurationDatabase(connectionString) with the configured " +
|
|
"'ScadaLink:Database:ConfigurationDb' value.");
|
|
}
|
|
}
|