diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs index d8a50d06..b3c67ec5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; @@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser }; private readonly ILogger _logger; + private readonly ISecretResolver _secretResolver; /// Creates a new browser. Logger defaults to . + /// Resolves the secret: arm of Gateway.ApiKeySecretRef (DI-injected). /// Optional logger; null is allowed for unit-test construction. - public GalaxyDriverBrowser(ILogger? logger = null) + public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger? logger = null) { + _secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)); _logger = logger ?? NullLogger.Instance; } @@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName)) throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName."); - var clientOpts = BuildClientOptions(opts.Gateway); + var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false); // 30s wall-clock budget for the connect phase, linked to the caller's token so // an AdminUI cancel still wins early. @@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser /// Build the gateway client options from the form's Gateway section. Mirrors the /// runtime driver's GalaxyDriver.BuildClientOptions field-for-field so the /// gateway sees an identical option shape. The API-key reference is resolved via - /// the shared in Driver.Galaxy.Contracts + /// the shared in Driver.Galaxy.Contracts /// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step. + /// The secret: arm is async, so the key is resolved into a local before the + /// object initializer (you can't await inside one). /// - private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() + private async Task BuildClientOptionsAsync( + GalaxyGatewayOptions gw, CancellationToken cancellationToken) { - Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), - ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger), - UseTls = gw.UseTls, - CaCertificatePath = gw.CaCertificatePath, - ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), - DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), - StreamTimeout = gw.StreamTimeoutSeconds > 0 - ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) - : null, - }; + var apiKey = await GalaxySecretRef + .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken) + .ConfigureAwait(false); + return new MxGatewayClientOptions + { + Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), + ApiKey = apiKey, + UseTls = gw.UseTls, + CaCertificatePath = gw.CaCertificatePath, + ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), + DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), + StreamTimeout = gw.StreamTimeoutSeconds > 0 + ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) + : null, + }; + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs index 0b8dbff9..18973165 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs @@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions( /// /// Connection details for the MxAccess gateway. is -/// resolved by at InitializeAsync time. Four forms +/// resolved by at InitializeAsync time. Five forms /// supported, in priority order: /// /// env:NAME — read from an environment variable (recommended for /// production; the central config DB holds only the indirection, not the key). /// file:PATH — read from an ACL'd file outside the repo. +/// secret:NAME — resolved through the shared ZB.MOM.WW.Secrets +/// encrypted store; fail-closed if absent (the production path). /// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; /// no startup warning. /// Anything else — treated as a literal cleartext API key for back-compat. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs index db123265..d7bca47f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs @@ -1,9 +1,10 @@ using Microsoft.Extensions.Logging; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; /// -/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. Four +/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. Five /// forms supported, evaluated in order: /// /// env:NAME — reads Environment.GetEnvironmentVariable(NAME). @@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; /// dev:KEY — explicit cleartext literal. The dev: prefix /// is a deliberate opt-in signal (dev box, parity rig) so the resolver /// doesn't emit a warning; production should never use this arm. +/// secret:NAME — resolves NAME through the shared +/// ZB.MOM.WW.Secrets (the encrypted-at-rest +/// store). Fail-closed: a secret: ref whose secret is absent/tombstoned +/// throws rather than falling through to the literal arm — the production path +/// that retires the cleartext dev:/literal-in-DB model. /// Anything else — used as the literal API key for back-compat with /// configs that pre-date this resolver. When a logger is supplied the /// resolver emits a startup warning so an operator who accidentally /// committed a cleartext key sees it. /// -/// A future PR can swap any of these arms for a DPAPI-backed lookup without +/// A future PR can swap any of these arms for a different backing store without /// changing the call site. /// /// @@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; public static class GalaxySecretRef { /// - /// Resolves the supplied secret reference. When the ref falls through to the - /// back-compat literal arm (an unprefixed cleartext API key in - /// DriverConfig JSON) and a is supplied, emits - /// a . The dev: prefix is the explicit - /// opt-in path that doesn't warn. + /// Resolves the supplied secret reference. The secret:NAME arm resolves + /// through and is fail-closed (throws when the secret + /// is absent). When the ref falls through to the back-compat literal arm (an + /// unprefixed cleartext API key in DriverConfig JSON) and a + /// is supplied, emits a . + /// The dev: prefix is the explicit opt-in path that doesn't warn. /// /// The secret reference string to resolve. + /// The shared secret resolver used by the secret: arm. /// Optional logger for warning on cleartext keys. + /// Cancellation token for the async secret: resolution. /// The resolved API-key string. - public static string ResolveApiKey(string secretRef, ILogger? logger = null) + public static async Task ResolveApiKeyAsync( + string secretRef, + ISecretResolver resolver, + ILogger? logger = null, + CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(secretRef); + ArgumentNullException.ThrowIfNull(resolver); if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) { @@ -76,6 +90,19 @@ public static class GalaxySecretRef return secretRef[4..]; } + if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) + { + // Production path: resolve the name through the shared encrypted secret store. + // Fail-closed — an absent/tombstoned secret throws rather than falling through + // to the literal arm (which would silently treat the ref string as the key). + var name = secretRef["secret:".Length..]; + var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); + return !string.IsNullOrEmpty(value) + ? value + : throw new InvalidOperationException( + $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed)."); + } + // Back-compat literal arm. An unprefixed string is treated as the literal // API key — but emit a warning so an operator who accidentally committed a // cleartext key into DriverConfig sees it. Use the dev: prefix to suppress diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 011b63ce..593e5efe 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; @@ -43,6 +44,12 @@ public sealed class GalaxyDriver private readonly GalaxyDriverOptions _options; private readonly ILogger _logger; + // Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store. + // Injected via ctor (the production factory pulls it from DI). The internal test ctor + // defaults it to a null-object resolver so the 45 seam-injecting test call sites keep + // compiling; those tests never use a secret: ref (they inject seams or use env/file/literal). + private readonly ISecretResolver _secretResolver; + // PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver // lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on // first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise @@ -147,14 +154,17 @@ public sealed class GalaxyDriver /// Initializes a new instance of the class. /// The unique identifier for this driver instance. /// The Galaxy driver configuration options. + /// Resolves the secret: arm of Gateway.ApiKeySecretRef. /// Optional logger instance for diagnostics. public GalaxyDriver( string driverInstanceId, GalaxyDriverOptions options, + ISecretResolver secretResolver, ILogger? logger = null) : this(driverInstanceId, options, hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null, - alarmAcknowledger: null, alarmFeed: null, logger) + alarmAcknowledger: null, alarmFeed: null, logger, + secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver))) { } @@ -173,6 +183,11 @@ public sealed class GalaxyDriver /// Optional custom alarm acknowledger for testing. /// Optional custom alarm feed for testing. /// Optional logger instance for diagnostics. + /// + /// Optional secret resolver for the secret: API-key arm. Defaults to a + /// null-object resolver (returns null for every name) so seam-injecting tests that + /// don't exercise a secret: ref need not supply one. + /// internal GalaxyDriver( string driverInstanceId, GalaxyDriverOptions options, @@ -182,13 +197,15 @@ public sealed class GalaxyDriver IGalaxySubscriber? subscriber = null, IGalaxyAlarmAcknowledger? alarmAcknowledger = null, IGalaxyAlarmFeed? alarmFeed = null, - ILogger? logger = null) + ILogger? logger = null, + ISecretResolver? secretResolver = null) { _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) ? driverInstanceId : throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? NullLogger.Instance; + _secretResolver = secretResolver ?? NullSecretResolver.Instance; _hierarchySource = hierarchySource; _dataReader = dataReader; _dataWriter = dataWriter; @@ -316,7 +333,7 @@ public sealed class GalaxyDriver _driverInstanceId); } - StartDeployWatcher(); + await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation( "GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}", _driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName); @@ -331,7 +348,7 @@ public sealed class GalaxyDriver /// private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken) { - var clientOptions = BuildClientOptions(_options.Gateway); + var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false); _ownedMxClient = MxGatewayClient.Create(clientOptions); _ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger); await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false); @@ -386,7 +403,7 @@ public sealed class GalaxyDriver private async Task ReopenAsync(CancellationToken cancellationToken) { if (_ownedMxSession is null) return; - var clientOptions = BuildClientOptions(_options.Gateway); + var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false); await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false); // The recreated session invalidates every prior gw item handle; drop the writer's handle/advise // caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session. @@ -527,32 +544,42 @@ public sealed class GalaxyDriver } } - private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() + private async Task BuildClientOptionsAsync( + GalaxyGatewayOptions gw, CancellationToken cancellationToken) { - Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), - // Pass the logger so the literal-arm cleartext fallback surfaces a startup - // warning rather than silently shipping the key. The resolver lives in - // Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the - // AdminUI browser share one implementation. - ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger), - UseTls = gw.UseTls, - CaCertificatePath = gw.CaCertificatePath, - ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), - DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), - StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null, - }; + // Resolve the API-key ref BEFORE the object initializer — the secret: arm is + // async and you can't await inside an initializer. Pass the logger so the + // literal-arm cleartext fallback surfaces a startup warning rather than + // silently shipping the key. The resolver lives in Driver.Galaxy.Contracts + // (GalaxySecretRef) so the runtime driver and the AdminUI browser share one + // implementation; the secret: arm resolves through the shared ISecretResolver. + var apiKey = await GalaxySecretRef + .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken) + .ConfigureAwait(false); + return new MxGatewayClientOptions + { + Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), + ApiKey = apiKey, + UseTls = gw.UseTls, + CaCertificatePath = gw.CaCertificatePath, + ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), + DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), + StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null, + }; + } - private void StartDeployWatcher() + private async Task StartDeployWatcherAsync(CancellationToken cancellationToken) { if (!_options.Repository.WatchDeployEvents) return; if (_ownedRepositoryClient is null && _hierarchySource is null) return; // Reuse the lazily-built repository client (DiscoverAsync constructs it on demand). // If discovery hasn't run yet, build the client here so the watcher has a target. - // Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client - // rather than overwriting the field and leaking the first instance. + // Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later + // runs it reuses this client rather than overwriting the field and leaking the + // first instance — the client-options build is now async (secret: arm). _ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create( - BuildClientOptions(_options.Gateway)); + await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false)); var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient); _deployWatcher = new DeployWatcher(source, _logger); @@ -691,7 +718,8 @@ public sealed class GalaxyDriver // After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so // newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute. var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); - var source = _hierarchySource ??= BuildDefaultHierarchySource(); + var source = _hierarchySource ??= + await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false); // Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as // its node reference (FullName) — the security map SecurityCapturingBuilder captures is then // keyed by RawPath, matching what WriteAsync resolves against. @@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver /// . Tests bypass this by injecting their own source via the /// internal ctor. /// - private IGalaxyHierarchySource BuildDefaultHierarchySource() + private async Task BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken) { - // Reuse a client that StartDeployWatcher may have already created (??=) rather + // Reuse a client that StartDeployWatcherAsync may have already created (??=) rather // than always overwriting the field and leaking the first instance. Both paths - // produce equivalent clients from the same options. - _ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway)); + // produce equivalent clients from the same options. The client-options build is + // async now (secret: arm resolves through the shared ISecretResolver). + _ownedRepositoryClient ??= GalaxyRepositoryClient.Create( + await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false)); return new TracedGalaxyHierarchySource( new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs index c8f075a7..877ed0bc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; @@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions { public const string DriverTypeName = "GalaxyMxGateway"; - /// Registers the Galaxy driver factory with the given registry and optional logger factory. + /// Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory. /// The driver factory registry to register with. + /// + /// The shared secret resolver (from DI) used to resolve the secret: arm of + /// each instance's Gateway.ApiKeySecretRef. + /// /// The optional logger factory for creating drivers. - public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + public static void Register( + DriverFactoryRegistry registry, + ISecretResolver secretResolver, + ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(registry); - registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + ArgumentNullException.ThrowIfNull(secretResolver); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver)); } - /// Convenience for tests + standalone callers. + /// + /// Convenience for tests + standalone callers. Uses the + /// null-object, so the secret: API-key arm resolves fail-closed (absent) — callers + /// that need a working secret: ref must use the path that + /// threads the DI resolver. + /// /// The unique identifier for the driver instance. /// The driver configuration in JSON format. /// The constructed Galaxy driver instance. public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson) - => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance); - /// Creates a Galaxy driver instance from configuration JSON with optional logger factory. + /// Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver. /// The unique identifier for the driver instance. /// The driver configuration in JSON format. /// The optional logger factory for creating drivers. + /// The secret resolver for the driver's secret: API-key arm. /// The constructed Galaxy driver instance. public static GalaxyDriver CreateInstance( - string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver) { + ArgumentNullException.ThrowIfNull(secretResolver); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions RawTags = dto.RawTags ?? [], }; - return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger()); + return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger()); } private static readonly JsonSerializerOptions JsonOptions = new() diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/NullSecretResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/NullSecretResolver.cs new file mode 100644 index 00000000..c001a547 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/NullSecretResolver.cs @@ -0,0 +1,26 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; + +/// +/// A null-object that resolves every name to null +/// (absent). Used as the default for the internal test ctor and the parse-only +/// CreateInstance path so callers that never exercise a secret: API-key +/// ref need not thread a real resolver. A production GalaxyDriver always receives +/// the DI-registered resolver via the factory; a secret: ref resolved against this +/// null object throws fail-closed (the secret is reported absent), which is the correct +/// behaviour for a mis-wired deployment. +/// +internal sealed class NullSecretResolver : ISecretResolver +{ + /// The shared singleton instance. + public static readonly NullSecretResolver Instance = new(); + + private NullSecretResolver() + { + } + + /// + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult(null); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 669822f3..9fcb27fe 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Resilience; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Host.Drivers; @@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap // The calc driver needs the root script logger so a script failure fans out onto the // script-logs topic; resolve it here (null on nodes without the script pipeline wired). var scriptRoot = sp.GetService(); - Register(registry, loggerFactory, scriptRoot); + // The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the + // shared ISecretResolver (registered unconditionally by AddZbSecrets on every node). + var secretResolver = sp.GetRequiredService(); + Register(registry, loggerFactory, secretResolver, scriptRoot); return registry; }); services.AddSingleton(sp => @@ -131,13 +135,14 @@ public static class DriverFactoryBootstrap private static void Register( DriverFactoryRegistry registry, ILoggerFactory? loggerFactory, + ISecretResolver secretResolver, ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null) { Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot); Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); - Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory); + Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory); Driver.S7.S7DriverFactoryExtensions.Register(registry); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs index daf6bb61..5ba06586 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs @@ -1,5 +1,6 @@ using Shouldly; using Xunit; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests; @@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests; [Trait("Category", "Unit")] public sealed class GalaxyDriverBrowserTests { - private readonly GalaxyDriverBrowser _sut = new(); + // These unit tests exercise only the pre-connect validation paths (empty endpoint, + // blank client name) that throw BEFORE the API-key secret: arm is resolved, so a + // null-returning stub resolver suffices — it is never consulted. + private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver()); + + /// A no-op resolver: every name resolves to null. Never consulted by these tests. + private sealed class StubSecretResolver : ISecretResolver + { + /// + public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); + } /// The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value /// so the factory wire-up picks the right browser implementation. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs index ba3a52b5..0f010b44 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverApiKeyResolverTests.cs @@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; /// -/// Follow-up #2 — pins the three resolution forms supported by -/// : env:NAME, file:PATH, -/// and the literal-string fallback. A future DPAPI arm slots in here without -/// touching the call site. (The resolver was extracted from GalaxyDriver to -/// the shared GalaxySecretRef in Driver.Galaxy.Contracts so the runtime -/// driver and the AdminUI browser share one copy.) +/// Follow-up #2 + G-2a — pins the five resolution forms supported by +/// : env:NAME, file:PATH, +/// dev:KEY, secret:NAME (via the shared ), and +/// the literal-string fallback. The secret: arm is fail-closed — an absent secret +/// throws rather than leaking the ref string as the key. (The resolver was extracted from +/// GalaxyDriver to the shared GalaxySecretRef in Driver.Galaxy.Contracts so the +/// runtime driver and the AdminUI browser share one copy.) /// public sealed class GalaxyDriverApiKeyResolverTests { + /// Name the fake resolver knows; matches the SecretName-normalized form. + private const string KnownSecretName = "galaxy/inst1/apikey"; + + /// The value the fake resolver returns for . + private const string KnownSecretValue = "key-from-secret-store"; + + /// A fake resolver: returns a known value for one known name, null otherwise. + private static ISecretResolver FakeResolver() => new StubSecretResolver(); + /// Verifies that a literal string is returned unchanged. [Fact] - public void Literal_string_is_returned_unchanged() + public async Task Literal_string_is_returned_unchanged() { - GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key"); + (await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver())) + .ShouldBe("plain-text-key"); } /// Verifies that env: prefix resolves to an environment variable. [Fact] - public void Env_prefix_resolves_to_environment_variable() + public async Task Env_prefix_resolves_to_environment_variable() { const string name = "OTOPCUA_TEST_GALAXY_API_KEY"; Environment.SetEnvironmentVariable(name, "key-from-env"); try { - GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env"); + (await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver())) + .ShouldBe("key-from-env"); } finally { @@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests /// Verifies that unset environment variables throw with a descriptive message. [Fact] - public void Env_prefix_unset_variable_throws_with_descriptive_message() + public async Task Env_prefix_unset_variable_throws_with_descriptive_message() { const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET"; Environment.SetEnvironmentVariable(name, null); - var ex = Should.Throw(() => - GalaxySecretRef.ResolveApiKey($"env:{name}")); + var ex = await Should.ThrowAsync(() => + GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver())); ex.Message.ShouldContain(name); ex.Message.ShouldContain("unset"); } /// Verifies that file: prefix resolves to trimmed file contents. [Fact] - public void File_prefix_resolves_to_trimmed_file_contents() + public async Task File_prefix_resolves_to_trimmed_file_contents() { var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt"); File.WriteAllText(path, " key-from-file \n"); try { - GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file"); + (await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())) + .ShouldBe("key-from-file"); } finally { @@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests /// Verifies that file: prefix with missing path throws. [Fact] - public void File_prefix_missing_path_throws() + public async Task File_prefix_missing_path_throws() { var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt"); - var ex = Should.Throw(() => - GalaxySecretRef.ResolveApiKey($"file:{path}")); + var ex = await Should.ThrowAsync(() => + GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())); ex.Message.ShouldContain(path); ex.Message.ShouldContain("doesn't exist"); } + // ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed ===== + + /// Verifies that secret: prefix resolves through the ISecretResolver. + [Fact] + public async Task Secret_prefix_resolves_through_secret_resolver() + { + (await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver())) + .ShouldBe(KnownSecretValue); + } + + /// Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string. + [Fact] + public async Task Secret_prefix_absent_secret_throws_fail_closed() + { + const string missingRef = "secret:galaxy/missing/apikey"; + + var ex = await Should.ThrowAsync(() => + GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver())); + + // Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and + // must NOT return the ref string as the key. + ex.Message.ShouldContain("galaxy/missing/apikey"); + ex.Message.ShouldContain("absent"); + } + + /// Verifies that the secret: arm does not consult the logger's literal warning. + [Fact] + public async Task Secret_prefix_does_not_emit_literal_warning() + { + var logger = new CaptureLogger(); + await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger); + logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); + } + // ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path ===== /// Verifies that literal strings emit a warning when a logger is supplied. [Fact] - public void Literal_string_emits_warning_when_logger_supplied() + public async Task Literal_string_emits_warning_when_logger_supplied() { // A literal API key on a production deployment means the cleartext key sits // in the DriverConfig JSON. The resolver must surface a warning so an // operator who committed one by accident sees it at startup. var logger = new CaptureLogger(); - var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger); + var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger); key.ShouldBe("plain-text-key"); logger.Entries.ShouldContain(e => @@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests /// Verifies that dev: prefix returns literal text without emitting warnings. [Fact] - public void Dev_prefix_returns_literal_without_warning() + public async Task Dev_prefix_returns_literal_without_warning() { // An explicit dev: prefix signals the operator knowingly opted into a literal // key (dev / parity rig). The resolver must accept it AND suppress the // warning so production logs aren't polluted on a deliberate dev choice. var logger = new CaptureLogger(); - var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger); + var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger); key.ShouldBe("plain-text-key"); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); @@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests /// Verifies that env: prefix does not emit literal string warnings. [Fact] - public void Env_prefix_does_not_emit_literal_warning() + public async Task Env_prefix_does_not_emit_literal_warning() { const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN"; Environment.SetEnvironmentVariable(name, "v"); try { var logger = new CaptureLogger(); - GalaxySecretRef.ResolveApiKey($"env:{name}", logger); + await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); } finally @@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests } } + /// Verifies that a null resolver is rejected (ArgumentNullException). + [Fact] + public async Task Null_resolver_throws() + { + await Should.ThrowAsync(() => + GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!)); + } + /// A test logger that captures log entries for verification. private sealed class CaptureLogger : ILogger { @@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests => Entries.Add((logLevel, formatter(state, exception))); } + /// A fake ISecretResolver returning one known value; null for every other name. + private sealed class StubSecretResolver : ISecretResolver + { + /// + public Task GetAsync(SecretName name, CancellationToken ct) => + Task.FromResult( + string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal) + ? KnownSecretValue + : null); + } + /// Verifies that file: prefix with empty file throws. [Fact] - public void File_prefix_empty_file_throws() + public async Task File_prefix_empty_file_throws() { var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt"); File.WriteAllText(path, " \n "); try { - var ex = Should.Throw(() => - GalaxySecretRef.ResolveApiKey($"file:{path}")); + var ex = await Should.ThrowAsync(() => + GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver())); ex.Message.ShouldContain("empty"); } finally diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs index 8e4e5ccc..2e3f6a3f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs @@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests public void Register_AddsFactoryToRegistry() { var registry = new DriverFactoryRegistry(); - GalaxyDriverFactoryExtensions.Register(registry); + GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance); registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs index d1b74038..d8974f7b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs @@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests // _dataReader and _subscriber are both null. The follow-up read path can't // synthesise a Read without one, so it surfaces a NotSupportedException // pointing at the misuse rather than NullRef'ing inside the pump path. - var driver = new GalaxyDriver("g", Opts()); + var driver = new GalaxyDriver("g", Opts(), hierarchySource: null); var ex = await Should.ThrowAsync(() => driver.ReadAsync(["x"], CancellationToken.None)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverSubscribeTests.cs index 42a94254..7d13f3e9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverSubscribeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverSubscribeTests.cs @@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests [Fact] public async Task SubscribeAsync_NoSubscriber_Throws() { - using var driver = new GalaxyDriver("g", Opts()); + using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null); var ex = await Should.ThrowAsync(() => driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None)); ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs index 9c08b942..63e83772 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs @@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests [Fact] public async Task WriteAsync_NoWriter_Throws() { - var driver = new GalaxyDriver("g", Opts()); + var driver = new GalaxyDriver("g", Opts(), hierarchySource: null); var ex = await Should.ThrowAsync(() => driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));