diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index bb72e4ddd..13b84b786 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -382,11 +382,10 @@ panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`. probe), browse (`BrowserSessionService`, before the browser) and at resolve time before any store/env read; `file:` is refused everywhere. A NEW driver that resolves a `secret:`/`env:` field and sends it to an authored endpoint MUST add a row there and resolve through `CredentialReferences` — never `ISecretResolver.GetAsync` on a raw authored name. - ⚠️ **Deprecated warn-only window:** a RUNNING Galaxy (init, every `ReopenAsync` gateway reconnect, ack fallback) or - OpcUaClient (session open) driver passes its `LegacyCredentialReferenceTracker` and so still resolves a pre-#815 - out-of-namespace ref with the OLD semantics + a rate-limited Warning + a `credential-compat:` diagnostics entry; - browsers/probes pass none (hard-fail), Heidenhain never has one. Chosen by call site — do not add a global flag, and do - not hand the tracker to an admin-node path. Upgrade preflight: `CredentialReferencePreflightService` + the `/raw` banner. + Resolve time is hard-fail for EVERY caller, running drivers included (Galaxy init / every `ReopenAsync` / ack fallback, + OpcUaClient session open) — the #815 warn-only window (`LegacyCredentialReferenceTracker`) was deleted by #834; do not + reintroduce a compatibility path. Preflight (hard error, ConfigDb only): `CredentialReferencePreflightService` + the + red `/raw` banner (Draft = next deploy refused, Deployed = driver not connecting). `docs/security.md` §"Credential references are namespaced per driver". - **…and per instance (#835):** a `secret://…` whose first segment `X` is ANOTHER same-type driver's id or name (case-insensitive; another-instance match beats an own-name match) is refused at save (`DriverConfigSaveGate.ValidateAsync`), @@ -405,7 +404,7 @@ panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`. `Drivers:OpcUaClient:UserCertificateDirectory` (`DriverCredentialPolicy` path field + `CredentialFilePaths`, all seams; load-time `ResolveWithin` = lexical + link-resolved containment, before the PFX password resolves). `LoadCertificate` refuses an unresolved (relative) path — build identities only from `ResolveSecretRefsAsync` output. A running driver's - pre-#836 ABSOLUTE path stays warn-only (`LegacyCredentialReferenceTracker.ReportPath`). + pre-#836 ABSOLUTE path is refused too (warn-only window removed by #834). - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/docs/security.md b/docs/security.md index fed4f750f..d1f8a1713 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1453,18 +1453,20 @@ Enforced, fail-closed, at every seam — the earlier ones exist for a clear mess 4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens. 5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` and `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope **before** any - secret-store or environment read. This is hard-fail on every admin-node path (the Galaxy and OpcUaClient - AdminUI browsers, every Test Connect probe) and everywhere for Heidenhain. The one exception is the deprecated - warn-only compatibility path for already-deployed drivers, below. + secret-store or environment read. This is hard-fail on **every** path — the running drivers (Galaxy + `InitializeAsync` / every gateway `ReopenAsync` / the ack fallback, OpcUaClient session open), the AdminUI + browsers and every Test Connect probe. The #815 warn-only window for already-deployed drivers is **over** + (lmxopcua#834, below). Messages name the field and the allowed forms, never the value. Out of scope by design: `${secret:}` tokens in `appsettings.json` (host config, not operator-authored), and the `Sql` driver's `connectionStringRef`, which is already a fixed prefix (`Sql__ConnectionStrings__`) whose value carries its own server — a Designer can choose *which* provisioned database a ref names, but cannot redirect its credentials to another host. -**Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the upgrade -preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message names the -migration), and refused by Test Connect / live browse. Values most likely to be in real config databases, with +**Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the +credential-reference preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message +names the migration), refused by Test Connect / live browse, and — since lmxopcua#834 — refused by a **running** +driver at resolve time: it does not connect until the field is migrated and redeployed. Values most likely to be in real config databases, with the exact replacement (create the new secret / variable first — on every node that runs the driver **and** on the admin nodes, which run Test Connect and browse — then edit the field): @@ -1480,30 +1482,27 @@ the admin nodes, which run Test Connect and browse — then edit the field): | OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/` | | OpcUaClient **literal** password that happens to start with `env:`, `file:`, `secret:`/`${secret:` in any casing (e.g. a real password `Env:Prod1`) — pre-#815 sent it verbatim | it now parses as a reference | store it: `secret:opcuaclient/` (a literal that does not start with one of those prefixes is unaffected) | -**Warn-only compatibility for already-deployed drivers (⚠️ deprecated — a later release fails closed).** Refusing -at resolve time would fault running plant drivers at the upgrade restart — and, for Galaxy, at **every gateway -reconnect**: `GalaxyDriver.ReopenAsync` (the `ReconnectSupervisor` callback) rebuilds the client options through -`BuildClientOptionsAsync`, which re-resolves `Gateway.ApiKeySecretRef` each time, so a running driver would fault -at its next reconnect, not at a deploy. So, for one compatibility window, the running-driver call sites — Galaxy -`InitializeAsync` / `ReopenAsync` / the ack-fallback resolver, and OpcUaClient session open — resolve an -out-of-namespace reference with the **old** semantics (Galaxy: case-insensitive `env:`/`file:`/`dev:`/`secret:`, -literal otherwise; OpcUaClient: case-insensitive `secret:`, literal otherwise) and log a Warning naming the -driver, field and allowed form (never the value), at most once per field per hour. The use also rides the -driver's diagnostics bag (`credential-compat:`) to `/hosts` and to the `/raw` credential banner. This is -chosen **by call site**, not by a flag (`LegacyCredentialReferenceTracker`): the AdminUI browsers and every probe -never get the tracker and stay hard-fail, and Heidenhain (new driver, no legacy configs) is hard-fail everywhere. -It cannot be used to exfiltrate: save, Test Connect, browse and the deploy gate all refuse such a reference, so it -can only be the reference an administrator deployed **before** the upgrade, and it lives only until that driver's -next successful deploy (which requires migrating it). The hard-fail release deletes the tracker, the per-driver -`Legacy…` resolve methods and their `legacy` parameters — nothing else changes. +**The compatibility window is over (lmxopcua#834, 2026-11-13).** The first #815 release let an already-deployed +Galaxy / OpcUaClient driver keep resolving a pre-#815 out-of-namespace reference with the old semantics, with a +rate-limited Warning and a `credential-compat:` diagnostics entry (`LegacyCredentialReferenceTracker`). That +path is deleted: every value in the table above — `env:MX_API_KEY` (the old Galaxy form default), any other +out-of-namespace `env:` / `secret:`, every `file:` and every non-lower-case prefix — now fails at resolve time with +the same `CredentialReferenceException` the save path, the deploy gate, Test Connect and browse raise, before any +store, environment or file read. For Galaxy that includes **every gateway reconnect** (`ReopenAsync` rebuilds the +client options through `BuildClientOptionsAsync`) and the ack fallback password (the acknowledge is refused); for +OpcUaClient it is the session open, before any endpoint is dialled. Removal was gated on the DEV and QA ConfigDbs +holding no Galaxy / OpcUaClient driver (checked 2026-11-13); no production install exists, so any future one gets the +strict behaviour from its first deploy. An upgrade from a pre-#815 build with such a config must migrate it **before** +the upgrade — the preflight below lists it. -**Upgrade preflight.** On an admin node, `CredentialReferencePreflightService` runs once, 15 s after -`ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only). It scans -every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft**) and the latest -deployment artifact (**Deployed** — what the running drivers were given) and logs one Warning per finding: -driver, device, field, allowed form, never the value. The same list, plus running drivers that reported a -warn-only resolution (**Running**, from driver-health diagnostics, fleet-wide), is shown as a banner on `/raw`, -with a Re-check button. +**Credential-reference preflight (a hard error).** On an admin node, `CredentialReferencePreflightService` runs +once, 15 s after `ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only). +It scans every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft** — a deploy the +gate will refuse) and the latest sealed deployment artifact (**Deployed** — a driver that refuses its credential and +does not connect) and logs one **Error** per finding: driver, device, field, allowed form, never the value. The same +list is shown as a red banner on `/raw`, with a Re-check button. It stays because it is the one place that names a +broken config *before* someone deploys it or wonders why a driver is down — e.g. after restoring an old ConfigDb +backup or importing a pre-#815 config — and it also carries the #835 cross-instance and #836/#850 path checks. #### Per-instance credential references (#835, #848, #849) @@ -1660,11 +1659,10 @@ service account can read (another product's client certificate, a host certifica - **The admin nodes and the driver nodes must hold the same relative layout** (the browse loads the certificate on the admin node). - `UserCertificatePassword` stays covered by #815 (`secret:opcuaclient/…` / `env:OTOPCUA_OPCUACLIENT_…` / literal). -- **Already-running drivers (deprecated warn-only window):** a deployed driver whose path is a pre-#836 **absolute** path - keeps loading it, with a rate-limited Warning (driver + field, never the path) and a - `credential-compat:UserCertificatePath` diagnostics entry, until its next deploy (which the gate refuses). The AdminUI - browse never gets the tracker (hard-fail). Migration: move the PFX under the directory on every node that runs the - driver **and** the admin nodes, then set the field to its relative path. +- **Already-running drivers:** the #836 warn-only window ended with lmxopcua#834 — a deployed driver whose path is a + pre-#836 **absolute** path now refuses it at session open (`CredentialReferenceException`), exactly like the browse and + the deploy gate. Migration: move the PFX under the directory on every node that runs the driver **and** the admin + nodes, then set the field to its relative path and redeploy. ### Role grant source diff --git a/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql b/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql index ff572fef9..f5acc4ac9 100644 --- a/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql +++ b/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql @@ -428,8 +428,8 @@ CROSS APPLY ( /* --------------------------------------------------------------------------------------------- Check 6 — #815 CredentialReferenceOutOfScope (+ #836 UserCertificatePath) (BLOCKING) master: Configuration/Validation/CredentialReferenceDeployChecks.cs (a224fd63 #815; 63331b04 #836; 3dbfe6ec #850). - A RUNNING driver keeps resolving an old reference warn-only (LegacyCredentialReferenceTracker), but the NEXT deploy - is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService + Since lmxopcua#834 a RUNNING driver also refuses such a reference at resolve time (it does not connect), and the + NEXT deploy is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService report (#834). Also lists config blobs that are not valid JSON (the gate reports those too). UserCertificatePath: must be blank or a relative path of [A-Za-z0-9._-] segments inside Drivers:OpcUaClient:UserCertificateDirectory; only checked where AuthType in the SAME object is absent/null/ diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs deleted file mode 100644 index 0551d4644..000000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs +++ /dev/null @@ -1,104 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -/// -/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential -/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy -/// InitializeAsync/ReopenAsync + ack fallback, OpcUaClient session open) passes its instance's tracker -/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of -/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass -/// and stay hard-fail. -/// -/// -/// -/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every -/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed -/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs -/// only until its next deploy (which the gate refuses until it is migrated). -/// -/// -/// Removing it (the planned hard-fail release): delete this type, the per-driver Legacy… resolve -/// methods it gates and the legacy parameters; every caller then takes the enforce branch it already -/// has. Nothing else changes. -/// -/// -/// Each use is reported through at most once per per field -/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as so the -/// driver's bag carries it to the AdminUI (/hosts and the /raw -/// credential banner). Messages name the driver, field and allowed forms — never the value. -/// -/// -/// The driver's scope (for the allowed forms). -/// The driver instance (for messages). -/// Sink for a rate-limited warning line. -/// Clock (tests); null ⇒ . -public sealed class LegacyCredentialReferenceTracker( - CredentialScope scope, string driverInstanceId, Action warn, Func? clock = null) -{ - /// The key prefix (one entry per field). - public const string DiagnosticsKeyPrefix = "credential-compat:"; - - /// Minimum spacing between two warnings for the same field. - public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1); - - private readonly object _gate = new(); - private readonly Dictionary _lastWarned = new(StringComparer.Ordinal); - private readonly SortedDictionary _diagnostics = new(StringComparer.Ordinal); - - /// The scope this tracker reports against. - public CredentialScope Scope => scope; - - /// The current legacy uses, keyed credential-compat:<field>. - public IReadOnlyDictionary Diagnostics - { - get - { - lock (_gate) return new Dictionary(_diagnostics, StringComparer.Ordinal); - } - } - - /// Records that was resolved through the legacy path (warns, rate-limited). - /// The field label. - public void Report(string field) - { - var forms = $"secret:{scope.SecretNamespaces[0]} or env:{scope.EnvVariablePrefixes[0]}"; - Record( - field, - $"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}", - $"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} " - + $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only " - + $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate " - + $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\"."); - } - - /// - /// Records that the key-material file field (lmxopcua#836, e.g. OpcUaClient - /// UserCertificatePath) was loaded from a path outside the node's certificate directory through the same - /// warn-only window (warns, rate-limited). Never carries the path. - /// - /// The field label. - /// How the operator configures the directory. - public void ReportPath(string field, string directoryDescription) => - Record( - field, - $"path outside the certificate directory still loaded (#836 warn-only, deprecated); next deploy refuses it — " - + $"move the file under {directoryDescription} and author it as a relative path", - $"{scope.Label} driver '{driverInstanceId}': {field} is not a relative path inside the node's certificate " - + $"directory ({directoryDescription}). It is still loaded for this already-deployed driver (lmxopcua#836 " - + "warn-only compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: move " - + "the file into that directory and set the field to its relative path before the next deploy. See " - + "docs/security.md §\"Key-material file paths are confined to a node directory\"."); - - private void Record(string field, string diagnostic, string warning) - { - var now = (clock ?? (() => DateTime.UtcNow))(); - bool log; - lock (_gate) - { - _diagnostics[DiagnosticsKeyPrefix + field] = diagnostic; - log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval; - if (log) _lastWarned[field] = now; - } - - if (log) warn(warning); - } -} 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 7af820c19..9072827cb 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 @@ -36,32 +36,19 @@ public static class GalaxySecretRef /// Optional logger for warning on cleartext keys. /// Cancellation token for the async secret: resolution. /// Environment reader (tests); null ⇒ the process environment. - /// - /// ⚠️ Deprecated #815 compatibility: the RUNNING driver passes its tracker, so a pre-#815 out-of-namespace - /// reference is resolved with the old semantics and warned about instead of failing. The AdminUI browser - /// passes null (hard-fail). See . - /// /// The resolved API-key string. public static async Task ResolveApiKeyAsync( string secretRef, ISecretResolver resolver, ILogger? logger = null, CancellationToken ct = default, - Func? environment = null, - LegacyCredentialReferenceTracker? legacy = null) + Func? environment = null) { ArgumentException.ThrowIfNullOrEmpty(secretRef); ArgumentNullException.ThrowIfNull(resolver); var scope = DriverCredentialPolicy.GalaxyScope; - var kind = CredentialReferences.Parse(secretRef, scope, out _); - if (kind == CredentialReferenceKind.OutOfScope && legacy is not null) - { - legacy.Report("Gateway.ApiKeySecretRef"); - return await LegacyResolveApiKeyAsync(secretRef, resolver, logger, ct, environment).ConfigureAwait(false); - } - - if (kind == CredentialReferenceKind.Literal) + if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal) { if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)) { @@ -77,7 +64,8 @@ public static class GalaxySecretRef return secretRef; } - // secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read. + // secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read — for + // every caller, the running driver included (lmxopcua#834 ended the #815 warn-only window). return await CredentialReferences.ResolveAsync( secretRef, scope, @@ -87,49 +75,4 @@ public static class GalaxySecretRef environment, ct).ConfigureAwait(false); } - - /// - /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, - /// verbatim — case-insensitive env: / file: / dev: / secret:, anything else a - /// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. - /// - private static async Task LegacyResolveApiKeyAsync( - string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func? environment) - { - if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) - { - var name = secretRef[4..]; - var value = (environment ?? Environment.GetEnvironmentVariable)(name); - return !string.IsNullOrEmpty(value) - ? value - : throw new InvalidOperationException( - $"Galaxy.Gateway.ApiKeySecretRef resolves to env var '{name}', but it is unset."); - } - - if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var path = secretRef[5..]; - if (!File.Exists(path)) - throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef points at '{path}', which doesn't exist."); - var contents = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim(); - return !string.IsNullOrEmpty(contents) - ? contents - : throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef file '{path}' is empty."); - } - - if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return secretRef[4..]; - - if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) - { - 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 resolves secret '{name}', but it is absent from the store (fail-closed)."); - } - - logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key."); - return secretRef; - } } 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 a929643da..50a77942a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -38,17 +38,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// /// public sealed class GalaxyDriver - : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, IAlarmAcknowledgeConfirmation, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable + : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, IAlarmAcknowledgeConfirmation, ISkippedTagReporter, IDisposable, IAsyncDisposable { - // ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815 - // out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the - // ack fallback password) with the old semantics, warning rate-limited and surfacing it on the diagnostics bag. - // The AdminUI browser and the probe never get a tracker, so they stay hard-fail. - private readonly LegacyCredentialReferenceTracker _legacyCredentials; - - /// - IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; - /// Authored tags this driver could not dial — a TagConfig with no usable /// attributeRef has no MXAccess address to bind. private readonly SkippedTagLedger _skippedTags = new(); @@ -391,8 +382,6 @@ public sealed class GalaxyDriver _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; - _legacyCredentials = new LegacyCredentialReferenceTracker( - DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message)); _hierarchySource = hierarchySource; _dataReader = dataReader; _dataWriter = dataWriter; @@ -400,7 +389,7 @@ public sealed class GalaxyDriver _alarmAcknowledger = alarmAcknowledger; _alarmFeed = alarmFeed; _ackCredentialResolver = ackCredentialResolver - ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials); + ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger); _ackConfirmations = new GalaxyAckConfirmationTracker( _options.AlarmAck?.ConfirmTimeout ?? GalaxyAckConfirmationTracker.DefaultWindow, _logger, _driverInstanceId); @@ -1488,7 +1477,8 @@ public sealed class GalaxyDriver } // Internal: the ONE client-options build InitializeAsync, ReopenAsync (every gateway reconnect) and the - // repository/alarm clients share — the #815 warn-only tests drive it directly to prove a reconnect re-resolves. + // repository/alarm clients share — the lmxopcua#834 tests drive it directly to prove a reconnect refuses an + // out-of-namespace reference exactly like the first init. internal async Task BuildClientOptionsAsync( GalaxyGatewayOptions gw, CancellationToken cancellationToken) { @@ -1499,7 +1489,7 @@ public sealed class GalaxyDriver // (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, legacy: _legacyCredentials) + .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken) .ConfigureAwait(false); return new MxGatewayClientOptions { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs index 6dc58a0a7..77b2ad751 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs @@ -22,7 +22,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver private readonly ISecretResolver _secrets; private readonly ILogger _logger; private readonly Func? _environment; - private readonly LegacyCredentialReferenceTracker? _legacy; /// Initializes a new instance of the class. /// The owning driver instance id (for messages). @@ -30,13 +29,11 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver /// The shared secret resolver. /// Logger; never receives a secret. /// Environment reader (tests); null ⇒ the process environment. - /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. public GalaxyAckCredentialResolver( string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger, - Func? environment = null, LegacyCredentialReferenceTracker? legacy = null) + Func? environment = null) { _environment = environment; - _legacy = legacy; _driverInstanceId = driverInstanceId; _options = options ?? new GalaxyAlarmAckOptions(); _secrets = secrets ?? throw new ArgumentNullException(nameof(secrets)); @@ -159,13 +156,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver /// internal async Task ResolvePasswordRefAsync(string reference, CancellationToken ct) { - if (_legacy is not null - && CredentialReferences.Parse(reference, DriverCredentialPolicy.GalaxyScope, out _) == CredentialReferenceKind.OutOfScope) - { - _legacy.Report("AlarmAck.FallbackPasswordSecretRef"); - return await LegacyResolvePasswordRefAsync(reference, ct).ConfigureAwait(false); - } - var resolved = await CredentialReferences.ResolveOrNullAsync( reference, DriverCredentialPolicy.GalaxyScope, @@ -179,28 +169,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver : resolved; } - /// - /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, - /// verbatim. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. - /// - private async Task LegacyResolvePasswordRefAsync(string reference, CancellationToken ct) - { - if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}')) - reference = "secret:" + reference["${secret:".Length..^1]; - if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)) - return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false); - if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) - return (_environment ?? Environment.GetEnvironmentVariable)(reference[4..]); - if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var path = reference[5..]; - return File.Exists(path) ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') : null; - } - - if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return reference[4..]; - throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/dev: reference, not a literal."); - } - // Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal. internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserCertificatePaths.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserCertificatePaths.cs index 3e59d1580..cad943a91 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserCertificatePaths.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserCertificatePaths.cs @@ -41,26 +41,16 @@ public static class OpcUaUserCertificatePaths /// /// Resolves an authored UserCertificatePath to the file to load, fail-closed - /// (). A fully-qualified (pre-#836) path is refused unless - /// is given (the RUNNING driver only — deprecated warn-only window), in which case it is - /// reported and returned verbatim, exactly as before #836. Anything else that is not an acceptable relative path is - /// always refused. + /// (): anything that is not an acceptable relative path — a + /// fully-qualified (pre-#836) path included — is refused, for the running driver too (lmxopcua#834 ended the + /// #836 warn-only window). /// /// The authored path. /// The directory; null ⇒ . - /// The running driver's tracker; null ⇒ hard-fail. /// The path to load. - public static string Resolve(string value, string? directory = null, LegacyCredentialReferenceTracker? legacy = null) + public static string Resolve(string value, string? directory = null) { var field = DriverCredentialPolicy.OpcUaClientUserCertificatePathField; - // Only a fully-qualified path is a pre-#836 value worth keeping alive: a relative one resolved against the - // service's working directory, which was never a meaningful configuration. - if (legacy is not null && !CredentialFilePaths.IsAcceptableRelative(value) && Path.IsPathFullyQualified(value)) - { - legacy.ReportPath(field.Name, field.DirectoryDescription); - return value; - } - return CredentialFilePaths.ResolveWithin( value, directory ?? CurrentDirectory, $"OpcUaClientDriverOptions.{field.Name}", field.DirectoryDescription); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index 7cda87446..27594db29 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -141,35 +141,29 @@ public static class OpcUaUserIdentityFactory /// The shared secret resolver, or null when none is available. /// Cancellation token. /// Environment reader (tests); null ⇒ the process environment. - /// - /// ⚠️ Deprecated #815 compatibility: the RUNNING driver's session open passes its tracker, so a pre-#815 - /// out-of-namespace value keeps the old semantics (a case-insensitive secret: resolves, anything else is - /// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail). - /// /// /// lmxopcua#836: the directory a identity's /// is resolved inside (relative path, lexical + /// link-resolved containment); null ⇒ . The returned - /// options carry the resolved full path. An absolute (pre-#836) path is refused unless - /// is given, in which case it is loaded as before with a warn-only report. + /// options carry the resolved full path. An absolute (pre-#836) path is refused (lmxopcua#834: for the running + /// driver too). /// /// An options copy whose credential fields carry resolved plaintext. public static async Task ResolveSecretRefsAsync( OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct, - Func? environment = null, LegacyCredentialReferenceTracker? legacy = null, - string? userCertificateDirectory = null) + Func? environment = null, string? userCertificateDirectory = null) { ArgumentNullException.ThrowIfNull(options); // #836 first: a refused certificate path fails before any secret/env read. var certPath = options.AuthType == OpcUaAuthType.Certificate && !string.IsNullOrWhiteSpace(options.UserCertificatePath) - ? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory, legacy) + ? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory) : options.UserCertificatePath; - var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy) + var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment) .ConfigureAwait(false); var certPassword = await ResolveFieldAsync( - options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy) + options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment) .ConfigureAwait(false); if (ReferenceEquals(password, options.Password) @@ -184,20 +178,15 @@ public static class OpcUaUserIdentityFactory private static async Task ResolveFieldAsync( string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct, - Func? environment, LegacyCredentialReferenceTracker? legacy) + Func? environment) { // A literal (or blank) passes through verbatim, reference-equal — the author already knows it. var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _); if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value; - if (kind == CredentialReferenceKind.OutOfScope && legacy is not null) - { - legacy.Report(fieldName); - return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false); - } - // lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and - // env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read. + // env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read — + // for the running driver's session open too (lmxopcua#834 ended the #815 warn-only window). return await CredentialReferences.ResolveAsync( value, DriverCredentialPolicy.OpcUaClientScope, @@ -207,24 +196,4 @@ public static class OpcUaUserIdentityFactory environment, ct).ConfigureAwait(false); } - - /// - /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, - /// verbatim — a case-insensitive secret: resolves (fail-closed), anything else passes through as a - /// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver. - /// - private static async Task LegacyResolveFieldAsync( - string value, string fieldName, ISecretResolver? resolver, CancellationToken ct) - { - if (!value.StartsWith(CredentialReferences.SecretPrefix, StringComparison.OrdinalIgnoreCase)) return value; - var name = value[CredentialReferences.SecretPrefix.Length..]; - if (resolver is null) - throw new InvalidOperationException( - $"OpcUaClientDriverOptions.{fieldName} references secret '{name}', but no secret store is available here (fail-closed)."); - var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false); - return !string.IsNullOrEmpty(resolved) - ? resolved - : throw new InvalidOperationException( - $"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed)."); - } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 06d462228..acba83198 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,16 +29,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// back on its own recovers in place. /// /// -public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, IDisposable, IAsyncDisposable +public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable { - // ⚠️ lmxopcua#815 warn-only compatibility (deprecated): the session open of this RUNNING driver keeps - // resolving a pre-#815 out-of-namespace credential with the old semantics, warning rate-limited and surfacing - // it on the diagnostics bag. The AdminUI browser never gets a tracker, so it stays hard-fail. - private readonly LegacyCredentialReferenceTracker _legacyCredentials; - - /// - IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; - /// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node /// to dial. This driver's skip path was the quietest of the twelve: NamespaceMap /// drops such an entry with a bare continue and not even a log line. @@ -90,8 +82,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _diag = new OpcUaClientDiagnostics(driverInstanceId); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; - _legacyCredentials = new LegacyCredentialReferenceTracker( - DriverCredentialPolicy.OpcUaClientScope, driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message)); _sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger); PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval; } @@ -564,7 +554,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a // single resolve covers both paths. var resolvedOptions = await OpcUaClientSecretResolution - .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false); + .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false); var identity = BuildUserIdentity(resolvedOptions); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs index 8e5eb290d..bda9a35dd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs @@ -31,18 +31,16 @@ internal static class OpcUaClientSecretResolution /// The raw driver options (credential fields may carry secret: refs). /// The shared secret resolver used by the secret: arm. /// Cancellation token for the async secret resolution. - /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. /// An options copy whose credential fields carry resolved plaintext. /// /// A secret: ref names a secret that is absent/tombstoned in the store (fail-closed). /// internal static Task ResolveSecretRefsAsync( - OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct, - Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null) + OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(resolver); // One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session). - return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy); + return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor index b3ff3b55d..3f100741d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor @@ -1,41 +1,38 @@ @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments -@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Configuration @inject CredentialReferencePreflightState State -@inject IDriverStatusSnapshotStore DriverStatus @inject IServiceProvider Services -@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their - driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the - deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. - The two sources are independent (lmxopcua#847): a failed ConfigDb scan shows a note, and the running findings - — read from the in-process health store — still render. See CredentialReferenceBannerModel. *@ +@* lmxopcua#815 credential-reference preflight surface — a hard error since #834: stored / deployed configs whose + credential references sit outside their driver's namespace (the boot scan, re-runnable here). The warn-only + compatibility path for running drivers is gone, so a Deployed finding is a driver that refuses the reference at + resolve time and does not connect; a Draft finding is a deploy the gate will refuse. Never shows a value. + A failed re-check keeps the last successful scan's findings and says so. *@ -@if (_model.Visible) +@if (_snapshot.Findings.Count > 0 || _snapshot.Error is not null) { -