Merge fix/815-credential-ref-scoping
# Conflicts: # .claude/rules/drivers.md # docs/drivers/Heidenhain.md
This commit is contained in:
@@ -338,6 +338,19 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
allowlist is unchanged. FOCAS transition rules; identity = class+group+number+TEXT (numbers repeat, `-1`). A failed walk
|
||||
(refusal/overflow/comms) is quality-only, never a clear. ⚠️ Ack is LOCAL-ONLY (`AcknowledgeAsync` no-op success) —
|
||||
never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented names or severities.
|
||||
- **Credential references are namespaced per driver (#815):** ONE table (`Core.Abstractions.DriverCredentialPolicy`) +
|
||||
ONE parser/resolver (`CredentialReferences`) for Galaxy (`secret:galaxy/`·`secret:otopcua/galaxy/`·`env:OTOPCUA_GALAXY_`·
|
||||
`env:GALAXY_MXGW_`), OpcUaClient (`secret:opcuaclient/`·`env:OTOPCUA_OPCUACLIENT_`) and Heidenhain. Enforced at save
|
||||
(`DriverConfigSaveGate`), deploy (`CredentialReferenceOutOfScope`), Test Connect (`AdminOperationsActor`, before the
|
||||
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:<field>` 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.
|
||||
`docs/security.md` §"Credential references are namespaced per driver".
|
||||
- **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
|
||||
|
||||
@@ -679,7 +679,8 @@ services:
|
||||
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
|
||||
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
|
||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY" (#815: GALAXY_MXGW_* is an allowed
|
||||
# Galaxy env prefix alongside OTOPCUA_GALAXY_*; any other variable is refused).
|
||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||
# Pair-local config cache + REPLICATION ON — the site-a pair is the enablement demo.
|
||||
# site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer.
|
||||
|
||||
@@ -385,7 +385,7 @@ The Galaxy/MxAccess connection settings are **not an `appsettings` section.** Th
|
||||
|
||||
| Record | Key fields (default) | Meaning |
|
||||
|---|---|---|
|
||||
| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `env:NAME` / `file:PATH` / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `env:`/`file:` in production. Never store a cleartext key. |
|
||||
| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `secret:galaxy/<name>` (or `secret:otopcua/galaxy/<name>`) / `env:OTOPCUA_GALAXY_<NAME>` (or legacy `env:GALAXY_MXGW_<NAME>`) / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `secret:` in production. Any other `secret:`/`env:` reference and every `file:` reference is refused at save, deploy (`CredentialReferenceOutOfScope`), Test Connect, browse and resolve — #815, see `docs/security.md` §"Credential references are namespaced per driver". Never store a cleartext key. |
|
||||
| `GalaxyMxAccessOptions` (`MxAccess`) | `ClientName`; `PublishingIntervalMs` (`1000`); `WriteUserId` (`0` = anonymous); `EventPumpChannelCapacity` (`50000`) | MXAccess client identity + tuning. `ClientName` **must be unique per OtOpcUa instance** (redundancy pairs enforce this). |
|
||||
| `GalaxyRepositoryOptions` (`Repository`) | `DiscoverPageSize` (`5000`); `WatchDeployEvents` (`true`) | Galaxy Repository browse paging + deploy-event watching. |
|
||||
| `GalaxyReconnectOptions` (`Reconnect`) | `InitialBackoffMs` (`500`); `MaxBackoffMs` (`30000`); `ReplayOnSessionLost` (`true`) | In-driver reconnect-supervisor backoff. |
|
||||
|
||||
@@ -81,7 +81,7 @@ Project root files:
|
||||
|
||||
`DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are:
|
||||
|
||||
- **`Gateway`** — endpoint, API key secret ref, TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime.
|
||||
- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/<name>` · `secret:otopcua/galaxy/<name>` · `env:OTOPCUA_GALAXY_<NAME>` · legacy `env:GALAXY_MXGW_<NAME>` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815 (an already-deployed driver keeps resolving a pre-#815 ref on init and every gateway reconnect in deprecated warn-only mode until its next deploy), [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime.
|
||||
- **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric).
|
||||
- **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`.
|
||||
- **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag).
|
||||
@@ -103,8 +103,10 @@ Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowle
|
||||
not silently downgraded to the fallback.
|
||||
2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` /
|
||||
`FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous
|
||||
sessions. The password ref must be `secret:NAME` / `${secret:NAME}` (production), `env:NAME`,
|
||||
`file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig.
|
||||
sessions. The password ref must be `secret:galaxy/NAME` / `secret:otopcua/galaxy/NAME` (or the `${secret:…}`
|
||||
spelling; production), `env:OTOPCUA_GALAXY_NAME` / `env:GALAXY_MXGW_NAME`, or `dev:VALUE`; a bare literal, a
|
||||
`file:` reference or a reference outside the Galaxy namespace is refused (#815) — the password is sent to the
|
||||
authored gateway endpoint, so it must not be able to name another subsystem's secret.
|
||||
3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the
|
||||
gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway
|
||||
health). How the client learns of it (#760):
|
||||
|
||||
@@ -96,7 +96,11 @@ Host at their own machine. Anything outside the namespace is refused by **every*
|
||||
(`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the
|
||||
driver/device modals disable Save while it is invalid and show the refusal **once**, below the form — the typed
|
||||
forms report only ranges/endpoints, #830 — so a literal never reaches the config database), and the deploy gate
|
||||
`HeidenhainCredentialPersisted`. Messages never echo the value.
|
||||
`HeidenhainCredentialPersisted`. Messages never echo the value. Since #815 the parser and resolver are
|
||||
the shared `CredentialReferences` / `DriverCredentialPolicy` (`Core.Abstractions`) that Galaxy and OpcUaClient use
|
||||
too, and Test Connect / live browse also refuse an out-of-namespace reference before any probe or browser runs; a
|
||||
prefix in another casing (`SECRET:`) is refused, and `${secret:heidenhain/<name>}` is accepted as a spelling of
|
||||
`secret:heidenhain/<name>`.
|
||||
|
||||
Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset
|
||||
variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the
|
||||
|
||||
@@ -81,6 +81,13 @@ for every field (security policy/mode, auth type, session timeout, keep-alive,
|
||||
browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect
|
||||
handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the
|
||||
factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save.
|
||||
**Credentials (#815).** `Password` / `UserCertificatePassword` are a literal, `secret:opcuaclient/<name>` (the node's
|
||||
secret store) or `env:OTOPCUA_OPCUACLIENT_<NAME>`. They are resolved on the node and sent to the configured endpoint,
|
||||
so any other `secret:`/`env:` reference (and `file:`) is refused at save, deploy (`CredentialReferenceOutOfScope`), Test
|
||||
Connect, browse and (for the AdminUI browser) again before anything is read; an already-deployed driver's session open
|
||||
keeps the pre-#815 semantics in deprecated warn-only mode until its next deploy. Migrate an existing `secret:<name>` by re-creating
|
||||
the secret as `opcuaclient/<name>`. See [`docs/security.md`](../security.md) §"Credential references are namespaced per driver".
|
||||
|
||||
`ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten
|
||||
the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing).
|
||||
|
||||
|
||||
@@ -1144,6 +1144,98 @@ Supporting hygiene, none of which is the control:
|
||||
If a deployment needs the browse surface narrower than "anyone who may author config", the lever is the
|
||||
page policy (and the LDAP group→role mapping behind it), not the browser.
|
||||
|
||||
#### Credential references are namespaced per driver (#815)
|
||||
|
||||
The outbound-connect capability above has a sharper edge whenever the config carries a **credential
|
||||
reference**: a Designer authors both the endpoint *and* the reference, and the node resolves the reference
|
||||
and sends the value to that endpoint — on Test Connect, on a live browse, and at runtime. Before #815 an
|
||||
unscoped reference let anyone who may author config exfiltrate any value the admin node could read, with no
|
||||
deploy: point a Galaxy `Gateway.Endpoint` at their own host, set `ApiKeySecretRef` to
|
||||
`env:ConnectionStrings__ConfigDb` (or `env:ServerHistorian__ApiKey`, or `file:<any path>`), and click Test
|
||||
Connect / browse; or set an OpcUaClient `Password` to `secret:<any store entry>`.
|
||||
|
||||
Every operator-authored reference is now confined to its driver's namespace — one table,
|
||||
`DriverCredentialPolicy` (`Core.Abstractions`), one parser/resolver, `CredentialReferences`:
|
||||
|
||||
| Driver | Fields | Allowed references | Literal |
|
||||
|---|---|---|---|
|
||||
| `GalaxyMxGateway` | `Gateway.ApiKeySecretRef` | `secret:galaxy/<name>`, `secret:otopcua/galaxy/<name>`, `env:OTOPCUA_GALAXY_<NAME>`, legacy `env:GALAXY_MXGW_<NAME>` | allowed (warns), `dev:<key>` |
|
||||
| `GalaxyMxGateway` | `AlarmAck.FallbackPasswordSecretRef` | same | `dev:<value>` only |
|
||||
| `OpcUaClient` | `Password`, `UserCertificatePassword` | `secret:opcuaclient/<name>`, `env:OTOPCUA_OPCUACLIENT_<NAME>` | allowed |
|
||||
| `Heidenhain` | `InspectPassword`, `DncPassword`, `DataPassword` | `secret:heidenhain/<name>`, `env:OTOPCUA_HEIDENHAIN_<NAME>` (#803) | refused |
|
||||
|
||||
Rules (all drivers): the prefixes are lower-case and exact (`SECRET:` / `Env:` are refused, never treated
|
||||
as a literal); `${secret:<name>}` is accepted as a spelling of `secret:<name>`; secret names are
|
||||
`[A-Za-z0-9._-]` segments with no empty, `.` or `..` segment; the variable suffix is `[A-Z0-9_]+`; **`file:` is
|
||||
refused everywhere** (Galaxy used to read any file the node could open). A field is matched at any depth of
|
||||
the DriverConfig / DeviceConfig, case-insensitively, as the drivers bind it.
|
||||
|
||||
Enforced, fail-closed, at every seam — the earlier ones exist for a clear message, the last one is the guarantee:
|
||||
|
||||
1. **AdminUI save** — `DriverConfigSaveGate` (`RawTreeService` driver/device create + update): the config is
|
||||
never stored.
|
||||
2. **Deploy gate** — `CredentialReferenceDeployChecks`, code **`CredentialReferenceOutOfScope`** (Heidenhain keeps
|
||||
`HeidenhainCredentialPersisted`).
|
||||
3. **Test Connect** — `AdminOperationsActor` refuses before any probe runs (`"Test Connect refused: …"`).
|
||||
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.
|
||||
|
||||
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__<ref>`) 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
|
||||
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):
|
||||
|
||||
| Old value | Why it is refused | Replace with |
|
||||
|---|---|---|
|
||||
| Galaxy `env:MX_API_KEY` — the **old AdminUI Galaxy form default** (pre-#815 `GalaxyDriverForm.razor`), so the likeliest value in a real DB | variable outside the namespace | rename the variable to `OTOPCUA_GALAXY_API_KEY` and set `env:OTOPCUA_GALAXY_API_KEY` (or store it and use `secret:galaxy/<inst>/apikey`) |
|
||||
| Galaxy `env:<ANY_OTHER_NAME>` (e.g. `env:MY_API_KEY`) | same | variable `OTOPCUA_GALAXY_<NAME>` → `env:OTOPCUA_GALAXY_<NAME>`. `env:GALAXY_MXGW_API_KEY` (docker-dev, parity docs) needs **no** change |
|
||||
| Galaxy `file:<path>` — `docs/Configuration.md` used to recommend it for production | `file:` is refused everywhere | put the file's contents in the store as `galaxy/<inst>/apikey` → `secret:galaxy/<inst>/apikey` (or a variable `OTOPCUA_GALAXY_<NAME>`) |
|
||||
| Galaxy `secret:<name>` not under `galaxy/` (e.g. the old `docs/v2/driver-specs.md` example `secret:galaxy-gw-api-key`) | secret outside the namespace | re-create it as `galaxy/<name>` (e.g. `galaxy/gw/apikey`) → `secret:galaxy/gw/apikey`. Names already under `galaxy/` or `otopcua/galaxy/` need no change |
|
||||
| Galaxy `Env:X`, `SECRET:x`, `File:x`, … (any non-lower-case prefix) — **pre-#815 resolved these case-insensitively** | prefixes are now exact | the lower-case namespaced form from the rows above |
|
||||
| Galaxy `AlarmAck.FallbackPasswordSecretRef` = `env:` / `file:` / `secret:` outside the namespace | same | as above; the documented `secret:otopcua/galaxy/<name>` needs no change |
|
||||
| OpcUaClient `Password` / `UserCertificatePassword` = `secret:<name>` not under `opcuaclient/` (e.g. `secret:opcua/plant/pw`) | secret outside the namespace | re-create the secret as `opcuaclient/<name>` → `secret:opcuaclient/<name>` |
|
||||
| OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/<name>` |
|
||||
| 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/<name>` (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:<field>`) 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.
|
||||
|
||||
**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.
|
||||
|
||||
Residual (accepted): within one namespace, a Designer can still point one driver instance at another
|
||||
instance's secret (e.g. `secret:galaxy/<other-inst>/apikey`) — the value then reaches the endpoint the Designer
|
||||
chose. Per-instance scoping is a possible follow-up.
|
||||
|
||||
### Role grant source
|
||||
|
||||
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI.
|
||||
|
||||
@@ -51,7 +51,7 @@ Per `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs`:
|
||||
{
|
||||
"Gateway": {
|
||||
"Endpoint": "http://localhost:5120",
|
||||
"ApiKeySecretRef": "secret:galaxy-gw-api-key",
|
||||
"ApiKeySecretRef": "secret:galaxy/gw/apikey",
|
||||
"UseTls": true,
|
||||
"CaCertificatePath": null,
|
||||
"ConnectTimeoutSeconds": 10,
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Deploy gate for credential references (lmxopcua#815): every DriverConfig and DeviceConfig of a driver type
|
||||
/// listed in <see cref="DriverCredentialPolicy"/> must carry its credential fields as blank, an acceptable
|
||||
/// literal, or a reference inside that driver's namespace (e.g. Galaxy <c>secret:galaxy/…</c> /
|
||||
/// <c>env:OTOPCUA_GALAXY_…</c>). The same table the AdminUI save path, Test Connect, live browse and every
|
||||
/// driver's resolve-time check read — so the gate and the runtime cannot disagree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Heidenhain is excluded here only because <see cref="HeidenhainDeployChecks"/> already reports the same rule
|
||||
/// (as <see cref="HeidenhainDeployChecks.CredentialCode"/>) through the shared parser; reporting it twice would
|
||||
/// duplicate every error. The message names the field and the allowed forms, never the value.
|
||||
/// </remarks>
|
||||
public static class CredentialReferenceDeployChecks
|
||||
{
|
||||
/// <summary>A credential field holding a reference outside the driver's namespace (or a refused literal).</summary>
|
||||
public const string Code = "CredentialReferenceOutOfScope";
|
||||
|
||||
/// <summary>
|
||||
/// Appended to every error: a config deployed before #815 is still RUNNING in warn-only compatibility mode, and
|
||||
/// this deploy is where it stops — so the message names the migration, not just the rule.
|
||||
/// </summary>
|
||||
public const string MigrationHint =
|
||||
" Migrate it before deploying: create the value under the allowed name (secret store via /admin/secrets or the "
|
||||
+ "secret CLI, or the environment variable on every node that runs this driver AND the admin nodes) and point the "
|
||||
+ "field at it. A driver deployed before this upgrade keeps resolving the old reference (warn-only, deprecated) "
|
||||
+ "only until this deploy succeeds. See docs/security.md §\"Credential references are namespaced per driver\".";
|
||||
|
||||
/// <summary>Runs the gate.</summary>
|
||||
/// <param name="draft">The draft.</param>
|
||||
/// <returns>The errors.</returns>
|
||||
public static IReadOnlyList<ValidationError> Errors(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
foreach (var driver in draft.DriverInstances)
|
||||
{
|
||||
if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
|
||||
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
|
||||
|
||||
foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)))
|
||||
{
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public static class DraftValidator
|
||||
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
|
||||
ValidateMTConnectSingleDevice(draft, errors);
|
||||
errors.AddRange(HeidenhainDeployChecks.Errors(draft));
|
||||
errors.AddRange(CredentialReferenceDeployChecks.Errors(draft));
|
||||
ValidateNodeAclScopes(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the
|
||||
/// secret-store prefix a <c>secret:</c> reference must start with, and the environment-variable prefixes an
|
||||
/// <c>env:</c> reference must start with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scope is the exfiltration guard. A Designer authors the endpoint AND the credential reference, and the
|
||||
/// node resolves the reference and sends the value to that endpoint (Test Connect, live browse, deploy). An
|
||||
/// unscoped reference could name any secret or process variable the node can read —
|
||||
/// <c>env:ConnectionStrings__ConfigDb</c>, <c>env:ServerHistorian__ApiKey</c>, the mesh PSK — and send it in
|
||||
/// cleartext to a host the Designer controls. Inside its own namespace a reference can only name values that
|
||||
/// were provisioned to be sent to that driver's kind of endpoint.
|
||||
/// </remarks>
|
||||
/// <param name="Label">Driver label used in messages (e.g. <c>Galaxy</c>).</param>
|
||||
/// <param name="SecretNamespaces">
|
||||
/// The ONLY secret-store prefixes allowed, each ending in <c>/</c> (e.g. <c>galaxy/</c>). The first entry is the
|
||||
/// documented form.
|
||||
/// </param>
|
||||
/// <param name="EnvVariablePrefixes">
|
||||
/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. <c>OTOPCUA_GALAXY_</c>); the remainder
|
||||
/// must be <c>[A-Z0-9_]+</c>. The first entry is the documented form.
|
||||
/// </param>
|
||||
public sealed record CredentialScope(string Label, IReadOnlyList<string> SecretNamespaces, IReadOnlyList<string> EnvVariablePrefixes);
|
||||
|
||||
/// <summary>Whether a credential field may carry a literal (non-reference) value.</summary>
|
||||
public enum CredentialLiteralPolicy
|
||||
{
|
||||
/// <summary>A literal is refused — the field must be blank or a namespaced reference.</summary>
|
||||
Refused,
|
||||
|
||||
/// <summary>A literal is accepted and used verbatim (the author already knows it; nothing is resolved).</summary>
|
||||
Allowed,
|
||||
|
||||
/// <summary>Only the explicit <c>dev:VALUE</c> literal is accepted (Galaxy's opt-in cleartext marker).</summary>
|
||||
DevPrefixOnly,
|
||||
}
|
||||
|
||||
/// <summary>The kind of a parsed credential field value.</summary>
|
||||
public enum CredentialReferenceKind
|
||||
{
|
||||
/// <summary>Null or empty.</summary>
|
||||
Blank,
|
||||
|
||||
/// <summary>A <c>secret:</c> (or <c>${secret:…}</c>) reference inside the scope's namespace.</summary>
|
||||
Secret,
|
||||
|
||||
/// <summary>An <c>env:</c> reference inside the scope's prefixes.</summary>
|
||||
Environment,
|
||||
|
||||
/// <summary>Not a reference: a literal value (acceptability depends on the field's <see cref="CredentialLiteralPolicy"/>).</summary>
|
||||
Literal,
|
||||
|
||||
/// <summary>
|
||||
/// A reference (<c>secret:</c>, <c>${secret:…}</c>, <c>env:</c>, <c>file:</c>) that is outside the scope or
|
||||
/// malformed. Never resolved; always refused.
|
||||
/// </summary>
|
||||
OutOfScope,
|
||||
}
|
||||
|
||||
/// <summary>A credential reference that may not (or cannot) be resolved. The message never carries a resolved value.</summary>
|
||||
/// <param name="message">The message.</param>
|
||||
public sealed class CredentialReferenceException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// The one parser + resolver of operator-authored credential references (<c>secret:</c> / <c>env:</c>), shared by
|
||||
/// every driver, probe, browser, the AdminUI save path and the deploy gate (lmxopcua#815, generalising #803).
|
||||
/// Resolution is fail-closed and re-checks the scope itself, so a reference that skipped every earlier gate is
|
||||
/// still never looked up.
|
||||
/// </summary>
|
||||
public static class CredentialReferences
|
||||
{
|
||||
/// <summary>Prefix of a secret-store reference.</summary>
|
||||
public const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>Prefix of an environment-variable reference.</summary>
|
||||
public const string EnvPrefix = "env:";
|
||||
|
||||
/// <summary>Prefix of a file reference — never allowed in operator-authored config (it reads any node file).</summary>
|
||||
public const string FilePrefix = "file:";
|
||||
|
||||
/// <summary>Galaxy's explicit cleartext marker.</summary>
|
||||
public const string DevPrefix = "dev:";
|
||||
|
||||
private const string TokenSecretPrefix = "${secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Parses <paramref name="value"/> against <paramref name="scope"/>. A prefix in another casing
|
||||
/// (<c>SECRET:x</c>) is <see cref="CredentialReferenceKind.OutOfScope"/> — never a literal sent verbatim and
|
||||
/// never resolved; the prefix, the namespace and the name are matched exactly.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored field value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <param name="name">The full secret name (incl. the namespace) or the environment variable; empty otherwise.</param>
|
||||
/// <returns>The kind.</returns>
|
||||
public static CredentialReferenceKind Parse(string? value, CredentialScope scope, out string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
name = "";
|
||||
if (string.IsNullOrEmpty(value)) return CredentialReferenceKind.Blank;
|
||||
|
||||
// A prefix in any casing is a reference (never a literal sent verbatim), but only the exact lower-case
|
||||
// spelling is ever resolved — 'SECRET:galaxy/x' is refused, not silently honoured.
|
||||
if (HasPrefixOtherCase(value, TokenSecretPrefix) || HasPrefixOtherCase(value, SecretPrefix)
|
||||
|| HasPrefixOtherCase(value, EnvPrefix))
|
||||
return CredentialReferenceKind.OutOfScope;
|
||||
|
||||
string? secret = null;
|
||||
if (value.StartsWith(TokenSecretPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (!value.EndsWith('}')) return CredentialReferenceKind.OutOfScope;
|
||||
secret = value[TokenSecretPrefix.Length..^1];
|
||||
}
|
||||
else if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
secret = value[SecretPrefix.Length..];
|
||||
}
|
||||
|
||||
if (secret is not null)
|
||||
{
|
||||
if (!IsScopedSecretName(secret, scope)) return CredentialReferenceKind.OutOfScope;
|
||||
name = secret;
|
||||
return CredentialReferenceKind.Secret;
|
||||
}
|
||||
|
||||
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var variable = value[EnvPrefix.Length..];
|
||||
if (!IsScopedVariable(variable, scope)) return CredentialReferenceKind.OutOfScope;
|
||||
name = variable;
|
||||
return CredentialReferenceKind.Environment;
|
||||
}
|
||||
|
||||
return value.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? CredentialReferenceKind.OutOfScope
|
||||
: CredentialReferenceKind.Literal;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="value"/> may be stored/deployed in a field with the given literal policy.</summary>
|
||||
/// <param name="value">The authored value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <param name="literals">The field's literal policy.</param>
|
||||
/// <returns>Whether it is acceptable.</returns>
|
||||
public static bool IsAcceptable(string? value, CredentialScope scope, CredentialLiteralPolicy literals) =>
|
||||
Parse(value, scope, out _) switch
|
||||
{
|
||||
CredentialReferenceKind.Blank or CredentialReferenceKind.Secret or CredentialReferenceKind.Environment => true,
|
||||
CredentialReferenceKind.Literal => literals switch
|
||||
{
|
||||
CredentialLiteralPolicy.Allowed => true,
|
||||
CredentialLiteralPolicy.DevPrefixOnly => value!.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>The one wording of the rule — names the allowed forms, never the value.</summary>
|
||||
/// <param name="field">The field.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <param name="literals">The field's literal policy.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}<NAME>'"));
|
||||
var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}<name>'"));
|
||||
var forms = $"{secrets} or {env}";
|
||||
return literals switch
|
||||
{
|
||||
CredentialLiteralPolicy.Allowed =>
|
||||
$"{field} must be a literal value, {forms} — a secret:/env:/file: reference outside the {scope.Label} "
|
||||
+ "namespace is refused (lmxopcua#815).",
|
||||
CredentialLiteralPolicy.DevPrefixOnly =>
|
||||
$"{field} must be blank, {forms} (or 'dev:<value>' on a dev rig) — a literal, a file: reference, or a "
|
||||
+ $"reference outside the {scope.Label} namespace is refused (lmxopcua#815).",
|
||||
_ =>
|
||||
$"{field} must be blank, {forms} — a literal password, or a reference outside the {scope.Label} "
|
||||
+ "namespace, is refused (lmxopcua#815).",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a field value, fail-closed. Blank returns <c>""</c>; an acceptable literal returns verbatim; an
|
||||
/// out-of-scope reference or a refused literal throws <see cref="CredentialReferenceException"/> BEFORE any
|
||||
/// store or environment read; an absent secret / unset variable returns <see langword="null"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <param name="field">Field label for messages.</param>
|
||||
/// <param name="literals">The field's literal policy.</param>
|
||||
/// <param name="secretLookup">Secret-store lookup (by full name); null ⇒ a <c>secret:</c> reference throws.</param>
|
||||
/// <param name="environment">Environment reader; null ⇒ <see cref="System.Environment.GetEnvironmentVariable(string)"/>.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The value, or null when the referenced value is absent.</returns>
|
||||
public static async ValueTask<string?> ResolveOrNullAsync(
|
||||
string? value,
|
||||
CredentialScope scope,
|
||||
string field,
|
||||
CredentialLiteralPolicy literals,
|
||||
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
|
||||
Func<string, string?>? environment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var kind = Parse(value, scope, out var name);
|
||||
if (!IsAcceptable(value, scope, literals))
|
||||
throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
|
||||
switch (kind)
|
||||
{
|
||||
case CredentialReferenceKind.Blank:
|
||||
return "";
|
||||
case CredentialReferenceKind.Literal:
|
||||
return value;
|
||||
case CredentialReferenceKind.Environment:
|
||||
return (environment ?? System.Environment.GetEnvironmentVariable)(name);
|
||||
case CredentialReferenceKind.Secret:
|
||||
if (secretLookup is null)
|
||||
throw new CredentialReferenceException(
|
||||
$"{field} references secret '{name}' but no secret store is available (fail-closed).");
|
||||
return await secretLookup(name, ct).ConfigureAwait(false);
|
||||
default:
|
||||
throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="ResolveOrNullAsync"/>, but an absent secret / unset (or empty) variable throws
|
||||
/// <see cref="CredentialReferenceException"/> naming the secret or variable (never a value).
|
||||
/// </summary>
|
||||
/// <param name="value">The authored value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <param name="field">Field label for messages.</param>
|
||||
/// <param name="literals">The field's literal policy.</param>
|
||||
/// <param name="secretLookup">Secret-store lookup; null ⇒ a <c>secret:</c> reference throws.</param>
|
||||
/// <param name="environment">Environment reader; null ⇒ the process environment.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The value (<c>""</c> for blank).</returns>
|
||||
public static async ValueTask<string> ResolveAsync(
|
||||
string? value,
|
||||
CredentialScope scope,
|
||||
string field,
|
||||
CredentialLiteralPolicy literals,
|
||||
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
|
||||
Func<string, string?>? environment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolved = await ResolveOrNullAsync(value, scope, field, literals, secretLookup, environment, ct).ConfigureAwait(false);
|
||||
var kind = Parse(value, scope, out var name);
|
||||
if (!string.IsNullOrEmpty(resolved) || kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal)
|
||||
return resolved ?? "";
|
||||
throw kind == CredentialReferenceKind.Environment
|
||||
? new CredentialReferenceException($"{field} environment variable '{name}' is not set (fail-closed).")
|
||||
: new CredentialReferenceException($"{field} secret '{name}' is absent from the store (fail-closed).");
|
||||
}
|
||||
|
||||
/// <summary>A print-safe form of a value: the reference itself, or a redaction for a literal / dev value.</summary>
|
||||
/// <param name="value">The authored value.</param>
|
||||
/// <returns>A string safe to log.</returns>
|
||||
public static string Describe(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "<blank>";
|
||||
var v = value.Trim();
|
||||
if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
|
||||
return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
|| v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? v
|
||||
: "<literal redacted>";
|
||||
}
|
||||
|
||||
private static bool HasPrefixOtherCase(string value, string prefix) =>
|
||||
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !value.StartsWith(prefix, StringComparison.Ordinal);
|
||||
|
||||
private static bool IsScopedSecretName(string secret, CredentialScope scope)
|
||||
{
|
||||
foreach (var ns in scope.SecretNamespaces)
|
||||
{
|
||||
if (!secret.StartsWith(ns, StringComparison.Ordinal)) continue;
|
||||
var rest = secret[ns.Length..];
|
||||
if (rest.Length is 0 or > 200) continue;
|
||||
if (!rest.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) continue;
|
||||
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) continue;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsScopedVariable(string variable, CredentialScope scope)
|
||||
{
|
||||
foreach (var prefix in scope.EnvVariablePrefixes)
|
||||
{
|
||||
if (!variable.StartsWith(prefix, StringComparison.Ordinal)) continue;
|
||||
var rest = variable[prefix.Length..];
|
||||
if (rest.Length is > 0 and <= 100 && rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>One credential field of a driver config and whether it may carry a literal.</summary>
|
||||
/// <param name="Name">The JSON property name (matched case-insensitively, as every driver binds).</param>
|
||||
/// <param name="Literals">The literal policy.</param>
|
||||
public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals);
|
||||
|
||||
/// <summary>The credential scope + fields of one driver type.</summary>
|
||||
/// <param name="DriverType">The <see cref="DriverTypeNames"/> value.</param>
|
||||
/// <param name="Scope">The namespace its references are confined to.</param>
|
||||
/// <param name="Fields">The credential fields its DriverConfig/DeviceConfig may carry.</param>
|
||||
public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields);
|
||||
|
||||
/// <summary>
|
||||
/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate,
|
||||
/// Test Connect, live browse and every driver's resolve-time check read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every driver that RESOLVES a <c>secret:</c>/<c>env:</c> reference from operator-authored config and sends the
|
||||
/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only —
|
||||
/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's
|
||||
/// <c>Sql__ConnectionStrings__<ref></c>, already a fixed prefix) are not.
|
||||
/// </remarks>
|
||||
public static class DriverCredentialPolicy
|
||||
{
|
||||
/// <summary>Heidenhain LSV2 passwords: <c>secret:heidenhain/…</c> / <c>env:OTOPCUA_HEIDENHAIN_…</c> (#803).</summary>
|
||||
public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]);
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy gateway API key + fallback ack password: <c>secret:galaxy/…</c> (or <c>secret:otopcua/galaxy/…</c>, the
|
||||
/// form the ack-fallback docs used) / <c>env:OTOPCUA_GALAXY_…</c>, plus the
|
||||
/// legacy <c>env:GALAXY_MXGW_…</c> prefix the docker-dev rig and the parity-rig docs already use
|
||||
/// (<c>GALAXY_MXGW_API_KEY</c>) — a variable provisioned to be sent to an mxaccessgw.
|
||||
/// </summary>
|
||||
public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]);
|
||||
|
||||
/// <summary>OPC UA Client user / certificate passwords: <c>secret:opcuaclient/…</c> / <c>env:OTOPCUA_OPCUACLIENT_…</c>.</summary>
|
||||
public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]);
|
||||
|
||||
/// <summary>Galaxy <c>Gateway.ApiKeySecretRef</c> (literal accepted for back-compat, with a warning at resolve).</summary>
|
||||
public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed);
|
||||
|
||||
/// <summary>Galaxy <c>AlarmAck.FallbackPasswordSecretRef</c> (reference or explicit <c>dev:</c> only).</summary>
|
||||
public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, DriverCredentialRules> Rules =
|
||||
new Dictionary<string, DriverCredentialRules>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope,
|
||||
[
|
||||
new("InspectPassword", CredentialLiteralPolicy.Refused),
|
||||
new("DncPassword", CredentialLiteralPolicy.Refused),
|
||||
new("DataPassword", CredentialLiteralPolicy.Refused),
|
||||
]),
|
||||
[DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]),
|
||||
[DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope,
|
||||
[
|
||||
new("Password", CredentialLiteralPolicy.Allowed),
|
||||
new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
|
||||
]),
|
||||
};
|
||||
|
||||
/// <summary>The rules for a driver type, if it resolves credential references.</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <param name="rules">The rules.</param>
|
||||
/// <returns>Whether the type has rules.</returns>
|
||||
public static bool TryGet(string? driverType, out DriverCredentialRules rules)
|
||||
{
|
||||
if (driverType is not null && Rules.TryGetValue(driverType, out var r))
|
||||
{
|
||||
rules = r;
|
||||
return true;
|
||||
}
|
||||
|
||||
rules = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of
|
||||
/// <paramref name="driverType"/> whose value is not acceptable — at ANY depth (a field is bound wherever the
|
||||
/// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value.
|
||||
/// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>The problems; empty when acceptable or the type has no credential rules.</returns>
|
||||
public static IReadOnlyList<string> DescribeProblems(string? driverType, string? json)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked.");
|
||||
return problems;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
Scan(doc.RootElement, "", rules, problems, 0);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List<string> problems, int depth)
|
||||
{
|
||||
if (depth > 32) return;
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
Scan(item, $"{path}[{i}]", rules, problems, depth + 1);
|
||||
i++;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object) return;
|
||||
foreach (var p in element.EnumerateObject())
|
||||
{
|
||||
var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}";
|
||||
var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (field is null)
|
||||
{
|
||||
Scan(p.Value, fieldPath, rules, problems, depth + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
var ok = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ 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
|
||||
/// <c>InitializeAsync</c>/<c>ReopenAsync</c> + 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
|
||||
/// <see langword="null"/> and stay hard-fail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Removing it (the planned hard-fail release): delete this type, the per-driver <c>Legacy…</c> resolve
|
||||
/// methods it gates and the <c>legacy</c> parameters; every caller then takes the enforce branch it already
|
||||
/// has. Nothing else changes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each use is reported through <paramref name="warn"/> at most once per <see cref="WarnInterval"/> per field
|
||||
/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as <see cref="Diagnostics"/> so the
|
||||
/// driver's <see cref="IDriverDiagnostics"/> bag carries it to the AdminUI (<c>/hosts</c> and the <c>/raw</c>
|
||||
/// credential banner). Messages name the driver, field and allowed forms — never the value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="scope">The driver's scope (for the allowed forms).</param>
|
||||
/// <param name="driverInstanceId">The driver instance (for messages).</param>
|
||||
/// <param name="warn">Sink for a rate-limited warning line.</param>
|
||||
/// <param name="clock">Clock (tests); null ⇒ <see cref="DateTime.UtcNow"/>.</param>
|
||||
public sealed class LegacyCredentialReferenceTracker(
|
||||
CredentialScope scope, string driverInstanceId, Action<string> warn, Func<DateTime>? clock = null)
|
||||
{
|
||||
/// <summary>The <see cref="Diagnostics"/> key prefix (one entry per field).</summary>
|
||||
public const string DiagnosticsKeyPrefix = "credential-compat:";
|
||||
|
||||
/// <summary>Minimum spacing between two warnings for the same field.</summary>
|
||||
public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, DateTime> _lastWarned = new(StringComparer.Ordinal);
|
||||
private readonly SortedDictionary<string, string> _diagnostics = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The scope this tracker reports against.</summary>
|
||||
public CredentialScope Scope => scope;
|
||||
|
||||
/// <summary>The current legacy uses, keyed <c>credential-compat:<field></c>.</summary>
|
||||
public IReadOnlyDictionary<string, string> Diagnostics
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate) return new Dictionary<string, string>(_diagnostics, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records that <paramref name="field"/> was resolved through the legacy path (warns, rate-limited).</summary>
|
||||
/// <param name="field">The field label.</param>
|
||||
public void Report(string field)
|
||||
{
|
||||
var forms = $"secret:{scope.SecretNamespaces[0]}<name> or env:{scope.EnvVariablePrefixes[0]}<NAME>";
|
||||
var now = (clock ?? (() => DateTime.UtcNow))();
|
||||
bool log;
|
||||
lock (_gate)
|
||||
{
|
||||
_diagnostics[DiagnosticsKeyPrefix + field] =
|
||||
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}";
|
||||
log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval;
|
||||
if (log) _lastWarned[field] = now;
|
||||
}
|
||||
|
||||
if (log)
|
||||
{
|
||||
warn($"{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\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,11 @@ public sealed record GalaxyDriverOptions(
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="FallbackPasswordSecretRef"/> must be a reference — <c>secret:NAME</c> (production),
|
||||
/// <c>env:NAME</c>, <c>file:PATH</c> or <c>dev:VALUE</c> (rigs only). A bare literal is rejected so a
|
||||
/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
|
||||
/// <see cref="FallbackPasswordSecretRef"/> must be a Galaxy-namespaced reference (lmxopcua#815) —
|
||||
/// <c>secret:galaxy/NAME</c> / <c>secret:otopcua/galaxy/NAME</c> (production), <c>env:OTOPCUA_GALAXY_NAME</c>
|
||||
/// / <c>env:GALAXY_MXGW_NAME</c>, or <c>dev:VALUE</c> (rigs only). A bare literal, <c>file:</c> and any other
|
||||
/// reference are rejected so a Galaxy password never sits in cleartext in the config DB and the field can
|
||||
/// never name another subsystem's secret.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="FallbackUser">Galaxy user the fallback acknowledges as; null/empty = no fallback.</param>
|
||||
@@ -84,20 +86,16 @@ public sealed record GalaxyAlarmAckOptions(
|
||||
|
||||
/// <summary>
|
||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||
/// supported, in priority order:
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. References are confined
|
||||
/// to the Galaxy namespace (lmxopcua#815 — the key is sent to the authored <c>Endpoint</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||
/// <item><c>secret:galaxy/NAME</c> (or <c>secret:otopcua/galaxy/NAME</c>) — the shared <c>ZB.MOM.WW.Secrets</c>
|
||||
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||
/// no startup warning.</item>
|
||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||
/// The resolver emits a <c>Warning</c> at startup so an operator who accidentally
|
||||
/// committed a cleartext key sees it; production should migrate to <c>env:</c>
|
||||
/// or <c>file:</c>.</item>
|
||||
/// <item><c>env:OTOPCUA_GALAXY_NAME</c> (or legacy <c>env:GALAXY_MXGW_NAME</c>) — an environment variable.</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests; no startup warning.</item>
|
||||
/// <item>Any other unprefixed string — a literal cleartext API key for back-compat, with a startup
|
||||
/// <c>Warning</c>.</item>
|
||||
/// <item>Any other <c>secret:</c>/<c>env:</c> reference, and every <c>file:</c> reference — refused.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
// PR 6.5 tuning notes:
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
||||
/// forms supported, evaluated in order:
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. The reference is resolved on the node
|
||||
/// and sent to the operator-authored <c>Gateway.Endpoint</c>, so references are confined to the Galaxy
|
||||
/// namespace (lmxopcua#815, <see cref="DriverCredentialPolicy.GalaxyScope"/>) and the check is repeated here, at
|
||||
/// resolve time, before any store or environment read:
|
||||
/// <list type="number">
|
||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||
/// Throws when the variable is unset, so a misconfigured deployment fails
|
||||
/// fast rather than silently sending an empty key.</item>
|
||||
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
|
||||
/// whitespace. Lets operators stash the key in an ACL'd file outside the
|
||||
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> 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.</item>
|
||||
/// <item><c>secret:NAME</c> — resolves NAME through the shared
|
||||
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
||||
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
||||
/// throws rather than falling through to the literal arm — the production path
|
||||
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
|
||||
/// <item>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.</item>
|
||||
/// <item><c>secret:galaxy/NAME</c> (or <c>${secret:galaxy/NAME}</c>) — resolved through the shared
|
||||
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/>; fail-closed when absent.</item>
|
||||
/// <item><c>env:OTOPCUA_GALAXY_NAME</c> / legacy <c>env:GALAXY_MXGW_NAME</c> — the process environment;
|
||||
/// fail-closed when unset.</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext literal (dev box, parity rig); no warning.</item>
|
||||
/// <item>Any other unprefixed string — the literal key (back-compat), with a warning when a logger is supplied.</item>
|
||||
/// <item>A <c>secret:</c>/<c>env:</c> reference OUTSIDE the namespace, and every <c>file:</c> reference (it
|
||||
/// read any file the node could open) — refused with <see cref="CredentialReferenceException"/>.</item>
|
||||
/// </list>
|
||||
/// A future PR can swap any of these arms for a different backing store without
|
||||
/// changing the call site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lives in the Contracts project so both the runtime <c>GalaxyDriver</c> and the
|
||||
@@ -37,80 +28,108 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
public static class GalaxySecretRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||
/// through <paramref name="resolver"/> 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 <c>DriverConfig</c> JSON) and a
|
||||
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
|
||||
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
|
||||
/// Resolves the supplied API-key reference (see the type remarks). Fail-closed for an out-of-namespace
|
||||
/// reference, a <c>file:</c> reference, an absent secret and an unset variable.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
|
||||
/// <param name="legacy">
|
||||
/// ⚠️ 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 <see cref="LegacyCredentialReferenceTracker"/>.
|
||||
/// </param>
|
||||
/// <returns>The resolved API-key string.</returns>
|
||||
public static async Task<string> ResolveApiKeyAsync(
|
||||
string secretRef,
|
||||
ISecretResolver resolver,
|
||||
ILogger? logger = null,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default,
|
||||
Func<string, string?>? environment = null,
|
||||
LegacyCredentialReferenceTracker? legacy = 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 (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal.
|
||||
return secretRef[CredentialReferences.DevPrefix.Length..];
|
||||
}
|
||||
|
||||
// Back-compat literal arm. Warn so an operator who accidentally committed a cleartext key sees it.
|
||||
logger?.LogWarning(
|
||||
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
|
||||
"Prefer secret:galaxy/<name> or env:OTOPCUA_GALAXY_<NAME>, or the explicit dev:KEY prefix for dev rigs — " +
|
||||
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
|
||||
return secretRef;
|
||||
}
|
||||
|
||||
// secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read.
|
||||
return await CredentialReferences.ResolveAsync(
|
||||
secretRef,
|
||||
scope,
|
||||
"Galaxy.Gateway.ApiKeySecretRef",
|
||||
DriverCredentialPolicy.GalaxyApiKeyField.Literals,
|
||||
async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
environment,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
|
||||
/// verbatim — case-insensitive <c>env:</c> / <c>file:</c> / <c>dev:</c> / <c>secret:</c>, anything else a
|
||||
/// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
|
||||
/// </summary>
|
||||
private static async Task<string> LegacyResolveApiKeyAsync(
|
||||
string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func<string, string?>? environment)
|
||||
{
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = secretRef[4..];
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
var value = (environment ?? Environment.GetEnvironmentVariable)(name);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
|
||||
$"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='{secretRef}' points at '{path}', which doesn't exist.");
|
||||
}
|
||||
var contents = File.ReadAllText(path).Trim();
|
||||
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='{secretRef}' file '{path}' is empty.");
|
||||
: throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef file '{path}' is empty.");
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a
|
||||
// cleartext literal (dev box, parity rig).
|
||||
return secretRef[4..];
|
||||
}
|
||||
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) 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).");
|
||||
$"Galaxy.Gateway.ApiKeySecretRef 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
|
||||
// this warning when the literal is intentional.
|
||||
logger?.LogWarning(
|
||||
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
|
||||
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
|
||||
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
|
||||
logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key.");
|
||||
return secretRef;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
|
||||
|
||||
/// <summary>Authored tags this driver could not dial — a TagConfig with no usable
|
||||
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -342,6 +351,8 @@ public sealed class GalaxyDriver
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_legacyCredentials = new LegacyCredentialReferenceTracker(
|
||||
DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
@@ -349,7 +360,7 @@ public sealed class GalaxyDriver
|
||||
_alarmAcknowledger = alarmAcknowledger;
|
||||
_alarmFeed = alarmFeed;
|
||||
_ackCredentialResolver = ackCredentialResolver
|
||||
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
|
||||
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials);
|
||||
|
||||
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
|
||||
// are authored (live-browse path), leaving every translation an identity no-op.
|
||||
@@ -1098,7 +1109,9 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
// 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.
|
||||
internal async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||
@@ -1108,7 +1121,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)
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
|
||||
+46
-37
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
@@ -20,15 +21,22 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||
private readonly GalaxyAlarmAckOptions _options;
|
||||
private readonly ISecretResolver _secrets;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<string, string?>? _environment;
|
||||
private readonly LegacyCredentialReferenceTracker? _legacy;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
|
||||
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
|
||||
/// <param name="options">The driver's ack-credential options.</param>
|
||||
/// <param name="secrets">The shared secret resolver.</param>
|
||||
/// <param name="logger">Logger; never receives a secret.</param>
|
||||
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
|
||||
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
|
||||
public GalaxyAckCredentialResolver(
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
_environment = environment;
|
||||
_legacy = legacy;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_options = options ?? new GalaxyAlarmAckOptions();
|
||||
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
|
||||
@@ -142,56 +150,57 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a password reference. Accepts <c>secret:NAME</c>, <c>${secret:NAME}</c>, <c>env:NAME</c>,
|
||||
/// <c>file:PATH</c> and <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
|
||||
/// <see cref="FormatException"/> for a bare literal (a cleartext password in DriverConfig).
|
||||
/// Resolves the fallback password reference through the shared lmxopcua#815 resolver: <c>secret:galaxy/NAME</c>
|
||||
/// (or <c>secret:otopcua/galaxy/NAME</c>, <c>${secret:…}</c>), <c>env:OTOPCUA_GALAXY_NAME</c> /
|
||||
/// <c>env:GALAXY_MXGW_NAME</c>, or <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
|
||||
/// <see cref="CredentialReferenceException"/> — before any store/environment read — for a bare literal, a
|
||||
/// <c>file:</c> reference, or a reference outside the Galaxy namespace (the password is sent to the
|
||||
/// operator-authored gateway endpoint, so it must never be able to name another subsystem's secret).
|
||||
/// </summary>
|
||||
internal async Task<string?> 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,
|
||||
"AlarmAck.FallbackPasswordSecretRef",
|
||||
DriverCredentialPolicy.GalaxyAckPasswordField.Literals,
|
||||
async (name, c) => await _secrets.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
_environment,
|
||||
ct).ConfigureAwait(false);
|
||||
return resolved is not null && reference.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? resolved[CredentialReferences.DevPrefix.Length..]
|
||||
: resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ 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.
|
||||
/// </summary>
|
||||
private async Task<string?> 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.GetEnvironmentVariable(reference[4..]);
|
||||
}
|
||||
|
||||
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;
|
||||
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:/file:/dev: reference, not a literal.");
|
||||
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)
|
||||
{
|
||||
var r = reference.Trim();
|
||||
if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
|
||||
if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
|
||||
|| r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
|
||||
|| r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
|
||||
|| r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return r;
|
||||
}
|
||||
|
||||
return "<literal redacted>";
|
||||
}
|
||||
internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference);
|
||||
}
|
||||
|
||||
+23
-86
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
@@ -21,14 +22,14 @@ public static class HeidenhainOptionsParser
|
||||
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in <c>A_LG</c> to a Host
|
||||
/// the author chose, so it must never be able to name another subsystem's secret.
|
||||
/// </summary>
|
||||
public const string SecretNamespace = "heidenhain/";
|
||||
public static readonly string SecretNamespace = DriverCredentialPolicy.HeidenhainScope.SecretNamespaces[0];
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY environment variables a Heidenhain password may name: <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>
|
||||
/// (exact upper-case prefix; <c>NAME</c> is <c>[A-Z0-9_]+</c>) — never <c>ConnectionStrings__*</c>,
|
||||
/// <c>ServerHistorian__ApiKey</c> or any other process variable.
|
||||
/// </summary>
|
||||
public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
|
||||
public static readonly string EnvVariablePrefix = DriverCredentialPolicy.HeidenhainScope.EnvVariablePrefixes[0];
|
||||
|
||||
/// <summary>The password fields a Heidenhain DriverConfig/DeviceConfig may carry.</summary>
|
||||
public static readonly IReadOnlyList<string> PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
|
||||
@@ -164,105 +165,41 @@ public static class HeidenhainOptionsParser
|
||||
/// <returns>True for blank or a namespaced reference.</returns>
|
||||
public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
|
||||
{
|
||||
kind = CredentialKind.None;
|
||||
name = "";
|
||||
if (string.IsNullOrEmpty(value)) return true;
|
||||
if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
|
||||
// The shared lmxopcua#815 parser — one rule for every driver that resolves credential references.
|
||||
switch (CredentialReferences.Parse(value, DriverCredentialPolicy.HeidenhainScope, out name))
|
||||
{
|
||||
var secret = value[SecretPrefix.Length..];
|
||||
if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
|
||||
var rest = secret[SecretNamespace.Length..];
|
||||
if (rest.Length is 0 or > 200) return false;
|
||||
foreach (var c in rest)
|
||||
if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
|
||||
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
|
||||
kind = CredentialKind.Secret;
|
||||
name = secret;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var variable = value[EnvPrefix.Length..];
|
||||
if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
|
||||
var rest = variable[EnvVariablePrefix.Length..];
|
||||
if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
|
||||
case CredentialReferenceKind.Blank:
|
||||
kind = CredentialKind.None;
|
||||
return true;
|
||||
case CredentialReferenceKind.Secret:
|
||||
kind = CredentialKind.Secret;
|
||||
return true;
|
||||
case CredentialReferenceKind.Environment:
|
||||
kind = CredentialKind.Environment;
|
||||
return true;
|
||||
default:
|
||||
kind = CredentialKind.None;
|
||||
name = "";
|
||||
return false;
|
||||
kind = CredentialKind.Environment;
|
||||
name = variable;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
|
||||
/// namespaced references — at the top level (a device / merged config), under <c>Access</c>, and under every
|
||||
/// <c>Devices[]</c> entry; keys matched case-insensitively, as the parser binds them. The messages name the
|
||||
/// field, never the value. Unreadable JSON is reported, since it cannot be checked.
|
||||
/// namespaced references — at any depth (top level, <c>Access</c>, <c>Devices[]</c>…); keys matched
|
||||
/// case-insensitively, as the parser binds them. The messages name the field, never the value. Unreadable
|
||||
/// JSON is reported, since it cannot be checked. Delegates to the shared <see cref="DriverCredentialPolicy"/>.
|
||||
/// </summary>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>The problems; empty when every password field is acceptable.</returns>
|
||||
public static IReadOnlyList<string> DescribeCredentialProblems(string? json)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(json)) return problems;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
|
||||
return problems;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
|
||||
ScanObject(doc.RootElement, "", problems);
|
||||
foreach (var p in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
|
||||
ScanObject(p.Value, "Access.", problems);
|
||||
if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var d in p.Value.EnumerateArray())
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
public static IReadOnlyList<string> DescribeCredentialProblems(string? json) =>
|
||||
DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Heidenhain, json);
|
||||
|
||||
/// <summary>The one wording of the credential rule (never includes the value).</summary>
|
||||
/// <param name="field">The field.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string CredentialRuleMessage(string field) =>
|
||||
$"{field} must be blank, 'secret:{SecretNamespace}<name>' or 'env:{EnvVariablePrefix}<NAME>' — a literal "
|
||||
+ "password, or a reference outside the Heidenhain namespace, is refused.";
|
||||
|
||||
private static void ScanObject(JsonElement obj, string prefix, List<string> problems)
|
||||
{
|
||||
foreach (var p in obj.EnumerateObject())
|
||||
{
|
||||
if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
var ok = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
|
||||
}
|
||||
}
|
||||
CredentialReferences.RuleMessage(field, DriverCredentialPolicy.HeidenhainScope, CredentialLiteralPolicy.Refused);
|
||||
|
||||
private static void CheckRef(List<string> errors, string field, string? value)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
@@ -31,26 +32,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
|
||||
return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Resolves one reference.</summary>
|
||||
/// <summary>Resolves one reference through the shared lmxopcua#815 resolver (scope re-checked before any read).</summary>
|
||||
public async ValueTask<string> ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
|
||||
{
|
||||
if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
|
||||
throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
|
||||
switch (kind)
|
||||
try
|
||||
{
|
||||
case HeidenhainOptionsParser.CredentialKind.None:
|
||||
return "";
|
||||
case HeidenhainOptionsParser.CredentialKind.Environment:
|
||||
return _environment(name) is { Length: > 0 } value
|
||||
? value
|
||||
: throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
|
||||
default:
|
||||
if (resolver is null)
|
||||
throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
|
||||
return await CredentialReferences.ResolveAsync(
|
||||
reference,
|
||||
DriverCredentialPolicy.HeidenhainScope,
|
||||
$"{role} password",
|
||||
CredentialLiteralPolicy.Refused,
|
||||
resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
_environment,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (CredentialReferenceException ex)
|
||||
{
|
||||
throw new HeidenhainCredentialException(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-18
@@ -1,5 +1,6 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
@@ -12,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// </summary>
|
||||
public static class OpcUaUserIdentityFactory
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Build the user-identity token from options whose credential fields are already resolved plaintext
|
||||
@@ -103,25 +103,34 @@ public static class OpcUaUserIdentityFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
|
||||
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
|
||||
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
|
||||
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
|
||||
/// Return a copy of <paramref name="options"/> with a <c>secret:opcuaclient/…</c> or
|
||||
/// <c>env:OTOPCUA_OPCUACLIENT_…</c> <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved (lmxopcua#815 namespace). Literal /
|
||||
/// null / empty fields pass through unchanged (the reference-equal original is returned when nothing changed).
|
||||
/// Fail-closed: a reference outside the namespace (or <c>file:</c>) throws before any read, and an absent
|
||||
/// secret, an unset variable, or a <c>secret:</c> ref with no resolver available throws rather than sending
|
||||
/// the reference string as a credential.
|
||||
/// </summary>
|
||||
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
|
||||
/// <param name="legacy">
|
||||
/// ⚠️ 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 <c>secret:</c> resolves, anything else is
|
||||
/// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail).
|
||||
/// </param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
@@ -134,24 +143,48 @@ public static class OpcUaUserIdentityFactory
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
|
||||
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct,
|
||||
Func<string, string?>? environment, LegacyCredentialReferenceTracker? legacy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
// 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)
|
||||
{
|
||||
return value;
|
||||
legacy.Report(fieldName);
|
||||
return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
// 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.
|
||||
return await CredentialReferences.ResolveAsync(
|
||||
value,
|
||||
DriverCredentialPolicy.OpcUaClientScope,
|
||||
$"OpcUaClientDriverOptions.{fieldName}",
|
||||
CredentialLiteralPolicy.Allowed,
|
||||
resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
environment,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
|
||||
/// verbatim — a case-insensitive <c>secret:</c> resolves (fail-closed), anything else passes through as a
|
||||
/// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver.
|
||||
/// </summary>
|
||||
private static async Task<string?> 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}='{value}' references secret '{name}', but no secret store is " +
|
||||
"available here (fail-closed).");
|
||||
$"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}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
$"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// back on its own recovers in place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
|
||||
|
||||
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
|
||||
/// to dial. This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
|
||||
/// drops such an entry with a bare <c>continue</c> and not even a log line.</summary>
|
||||
@@ -82,6 +90,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_diag = new OpcUaClientDiagnostics(driverInstanceId);
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.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;
|
||||
}
|
||||
@@ -554,7 +564,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).ConfigureAwait(false);
|
||||
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false);
|
||||
|
||||
var identity = BuildUserIdentity(resolvedOptions);
|
||||
|
||||
|
||||
@@ -31,16 +31,18 @@ internal static class OpcUaClientSecretResolution
|
||||
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||
/// </exception>
|
||||
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct,
|
||||
Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
|
||||
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
|
||||
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
@@ -36,6 +37,13 @@ public sealed class BrowserSessionService(
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||
{
|
||||
// lmxopcua#815: a live browse resolves the config's credential references and sends them to the
|
||||
// author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
|
||||
// Discover-backed universal browser) is opened. Nothing is read from the store or environment here.
|
||||
var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
|
||||
if (credentialProblems.Count > 0)
|
||||
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
|
||||
|
||||
if (!_browsersByType.TryGetValue(driverType, out var browser))
|
||||
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
|
||||
try
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<span class="text-muted small">Changes apply on the next deployment.</span>
|
||||
</div>
|
||||
|
||||
<CredentialReferenceBanner />
|
||||
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span>Project tree</span>
|
||||
|
||||
+6
-6
@@ -21,8 +21,8 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">API key secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="env:MX_API_KEY" />
|
||||
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
||||
placeholder="env:OTOPCUA_GALAXY_API_KEY" />
|
||||
<div class="form-text">Forms: <code>secret:galaxy/NAME</code>, <code>env:OTOPCUA_GALAXY_NAME</code> (or legacy <code>env:GALAXY_MXGW_NAME</code>), <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and <code>file:</code> are refused (#815).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
@@ -107,7 +107,7 @@
|
||||
<label class="form-label">Fallback password secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
|
||||
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
|
||||
<div class="form-text">Forms: <code>secret:galaxy/NAME</code> or <code>secret:otopcua/galaxy/NAME</code> (production), <code>env:OTOPCUA_GALAXY_NAME</code>, <code>dev:VALUE</code>. A bare literal, <code>file:</code>, or a reference outside the Galaxy namespace is refused (#815).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@
|
||||
}
|
||||
|
||||
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"),
|
||||
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
||||
Repository: new GalaxyRepositoryOptions(),
|
||||
Reconnect: new GalaxyReconnectOptions());
|
||||
@@ -241,7 +241,7 @@
|
||||
{
|
||||
// GalaxyGatewayOptions
|
||||
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY";
|
||||
public bool GatewayUseTls { get; set; } = true;
|
||||
public string? GatewayCaCertificatePath { get; set; }
|
||||
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||
{
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY");
|
||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||
|
||||
+2
@@ -138,6 +138,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>. Other references are refused (#815).</div>
|
||||
</div>
|
||||
}
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
|
||||
@@ -150,6 +151,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
@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. *@
|
||||
|
||||
@if (_findings.Count > 0 || _error is not null)
|
||||
{
|
||||
<div class="alert alert-warning small mb-3" role="alert">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<strong>Credential references outside their driver's namespace (lmxopcua#815).</strong>
|
||||
The deploy gate refuses these; a driver deployed before the upgrade keeps resolving them in
|
||||
deprecated warn-only mode until its next deploy. Migrate each to the allowed form — see
|
||||
docs/security.md "Credential references are namespaced per driver".
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
|
||||
</div>
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="text-danger mt-1">@_error</div>
|
||||
}
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var f in _findings.Take(MaxRows))
|
||||
{
|
||||
<li>
|
||||
<span class="badge bg-secondary me-1">@f.Source</span>
|
||||
@if (!string.IsNullOrEmpty(f.DriverType)) { <span>@f.DriverType </span> }
|
||||
driver <span class="mono">@f.DriverName</span>
|
||||
@if (f.DeviceName is not null) { <span> device <span class="mono">@f.DeviceName</span></span> }
|
||||
— @f.Problem
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (_findings.Count > MaxRows)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int MaxRows = 25;
|
||||
private IReadOnlyList<CredentialReferenceFinding> _findings = [];
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
protected override void OnInitialized() => Compose(State.Current);
|
||||
|
||||
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
|
||||
{
|
||||
_error = snapshot.Error;
|
||||
_findings = snapshot.Findings
|
||||
.Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task RecheckAsync()
|
||||
{
|
||||
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
|
||||
if (factory is null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
|
||||
var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
|
||||
State.Set(snapshot);
|
||||
Compose(snapshot);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_error = "The credential-reference preflight could not read the config DB.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
|
||||
|
||||
/// <summary>Where a credential-reference finding came from.</summary>
|
||||
public enum CredentialFindingSource
|
||||
{
|
||||
/// <summary>A stored DriverConfig / DeviceConfig row (what the next deploy will gate).</summary>
|
||||
Draft,
|
||||
|
||||
/// <summary>The latest deployment artifact (what the running drivers were given).</summary>
|
||||
Deployed,
|
||||
|
||||
/// <summary>A running driver that reported resolving it through the #815 warn-only compatibility path.</summary>
|
||||
Running,
|
||||
}
|
||||
|
||||
/// <summary>One out-of-namespace credential reference (never carries the value).</summary>
|
||||
/// <param name="Source">Where it was found.</param>
|
||||
/// <param name="DriverType">The driver type.</param>
|
||||
/// <param name="DriverName">The driver's display name (or instance id for a running driver).</param>
|
||||
/// <param name="DriverInstanceId">The driver instance id.</param>
|
||||
/// <param name="DeviceName">The device, when the reference sits in a DeviceConfig.</param>
|
||||
/// <param name="Problem">The field and the allowed forms.</param>
|
||||
public sealed record CredentialReferenceFinding(
|
||||
CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem);
|
||||
|
||||
/// <summary>
|
||||
/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential
|
||||
/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that
|
||||
/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode).
|
||||
/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment.
|
||||
/// </summary>
|
||||
/// <param name="dbFactory">The config DB.</param>
|
||||
public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
/// <summary>Scans the draft rows and the latest deployment artifact.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The findings.</returns>
|
||||
public async Task<IReadOnlyList<CredentialReferenceFinding>> ScanAsync(CancellationToken ct)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var drivers = await db.DriverInstances.AsNoTracking().ToListAsync(ct).ConfigureAwait(false);
|
||||
var ruled = drivers.Where(d => DriverCredentialPolicy.TryGet(d.DriverType, out _)).ToList();
|
||||
var ids = ruled.Select(d => d.DriverInstanceId).ToHashSet(StringComparer.Ordinal);
|
||||
var devices = ids.Count == 0
|
||||
? []
|
||||
: await db.Devices.AsNoTracking().Where(d => ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false);
|
||||
var latestBlob = await db.Deployments.AsNoTracking()
|
||||
.Where(d => d.ArtifactBlob.Length > 0)
|
||||
.OrderByDescending(d => d.CreatedAtUtc)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
|
||||
var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob);
|
||||
return Scan(ruled, devices, deployed);
|
||||
}
|
||||
|
||||
/// <summary>The pure scan (tests drive it directly).</summary>
|
||||
/// <param name="drivers">Stored driver rows.</param>
|
||||
/// <param name="devices">Stored device rows.</param>
|
||||
/// <param name="deployed">The latest artifact's driver specs.</param>
|
||||
/// <returns>The findings.</returns>
|
||||
public static IReadOnlyList<CredentialReferenceFinding> Scan(
|
||||
IEnumerable<DriverInstance> drivers, IEnumerable<Device> devices, IEnumerable<DriverInstanceSpec> deployed)
|
||||
{
|
||||
var findings = new List<CredentialReferenceFinding>();
|
||||
var driverList = drivers.ToList();
|
||||
var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
||||
foreach (var d in driverList)
|
||||
{
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig))
|
||||
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
|
||||
}
|
||||
|
||||
foreach (var dev in devices)
|
||||
{
|
||||
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig))
|
||||
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
|
||||
}
|
||||
|
||||
foreach (var s in deployed)
|
||||
{
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig))
|
||||
findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Running drivers that reported a warn-only legacy resolution on their diagnostics bag
|
||||
/// (<see cref="LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix"/>), fleet-wide via the driver-health feed.
|
||||
/// </summary>
|
||||
/// <param name="snapshots">The driver-health snapshots.</param>
|
||||
/// <returns>The findings.</returns>
|
||||
public static IReadOnlyList<CredentialReferenceFinding> RunningFindings(IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> snapshots) =>
|
||||
snapshots
|
||||
.Where(s => s.Diagnostics is not null)
|
||||
.SelectMany(s => s.Diagnostics!
|
||||
.Where(kv => kv.Key.StartsWith(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix, StringComparison.Ordinal))
|
||||
.Select(kv => new CredentialReferenceFinding(
|
||||
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
|
||||
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
|
||||
public sealed class CredentialReferencePreflightState
|
||||
{
|
||||
private volatile Snapshot _current = new([], null, null);
|
||||
|
||||
/// <summary>An immutable result.</summary>
|
||||
/// <param name="Findings">The findings.</param>
|
||||
/// <param name="ScannedAtUtc">When the scan ran (null = not yet).</param>
|
||||
/// <param name="Error">A scan failure, if any.</param>
|
||||
public sealed record Snapshot(IReadOnlyList<CredentialReferenceFinding> Findings, DateTime? ScannedAtUtc, string? Error);
|
||||
|
||||
/// <summary>The current result.</summary>
|
||||
public Snapshot Current => _current;
|
||||
|
||||
/// <summary>Replaces the result.</summary>
|
||||
/// <param name="snapshot">The new result.</param>
|
||||
public void Set(Snapshot snapshot) => _current = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs <see cref="CredentialReferencePreflight"/> once after host start on an admin node and logs one Warning per
|
||||
/// finding (driver, device, field, allowed forms — never a value). Scheduled after <c>ApplicationStarted</c> plus a
|
||||
/// short delay, off every Akka starter; it needs only the ConfigDb, never <c>ISecretResolver</c>.
|
||||
/// </summary>
|
||||
/// <param name="services">Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped).</param>
|
||||
/// <param name="lifetime">Host lifetime.</param>
|
||||
/// <param name="state">Where the result is kept for the AdminUI.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public sealed class CredentialReferencePreflightService(
|
||||
IServiceProvider services,
|
||||
IHostApplicationLifetime lifetime,
|
||||
CredentialReferencePreflightState state,
|
||||
ILogger<CredentialReferencePreflightService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Delay after <c>ApplicationStarted</c> before the scan (tests set it to zero).</summary>
|
||||
public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (lifetime.ApplicationStarted.Register(() => started.TrySetResult()))
|
||||
using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken)))
|
||||
{
|
||||
try
|
||||
{
|
||||
await started.Task.ConfigureAwait(false);
|
||||
if (StartDelay > TimeSpan.Zero) await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await RunOnceAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>One scan + log pass (internal so tests drive it directly).</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The findings.</returns>
|
||||
internal async Task<IReadOnlyList<CredentialReferenceFinding>> RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var dbFactory = services.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||
if (dbFactory is null)
|
||||
{
|
||||
logger.LogDebug("Credential-reference preflight skipped: no ConfigDb on this node.");
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(dbFactory).ScanAsync(ct).ConfigureAwait(false);
|
||||
state.Set(new(findings, DateTime.UtcNow, null));
|
||||
foreach (var f in findings)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
|
||||
+ "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. "
|
||||
+ "See docs/security.md \"Credential references are namespaced per driver\".",
|
||||
f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem);
|
||||
}
|
||||
|
||||
if (findings.Count == 0)
|
||||
logger.LogInformation("Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.");
|
||||
return findings;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB.");
|
||||
state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
|
||||
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
// lmxopcua#815 upgrade preflight: one ConfigDb scan after start (no secret resolution), WARN per
|
||||
// out-of-namespace credential reference, shown by the /raw credential banner.
|
||||
services.AddSingleton<Deployments.CredentialReferencePreflightState>();
|
||||
services.AddHostedService<Deployments.CredentialReferencePreflightService>();
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
@@ -10,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// block Save while the form is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
|
||||
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
|
||||
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
|
||||
/// Today it carries one rule, for every driver that resolves credential references
|
||||
/// (<see cref="DriverCredentialPolicy"/> — Heidenhain, Galaxy, OpcUaClient): a credential field must be blank,
|
||||
/// an acceptable literal for that field, or a reference inside the driver's namespace (lmxopcua#803/#815) —
|
||||
/// otherwise an exfiltration-shaped reference (or a Heidenhain literal password) would be persisted in a draft
|
||||
/// (and replicated) even though the deploy gate and every resolver would later refuse it.
|
||||
/// </remarks>
|
||||
public static class DriverConfigSaveGate
|
||||
{
|
||||
@@ -22,8 +23,7 @@ public static class DriverConfigSaveGate
|
||||
/// <returns>The refusal (never containing a credential value), or null.</returns>
|
||||
public static string? Validate(string? driverType, string? configJson)
|
||||
{
|
||||
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
|
||||
var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +585,20 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// lmxopcua#815: refuse a credential reference outside the driver's namespace BEFORE any probe runs — the
|
||||
// probe would otherwise be the seam that resolves it and sends it to the author-chosen endpoint. Nothing
|
||||
// is read from the secret store or the environment on this path.
|
||||
var credentialProblems = DriverCredentialPolicy.DescribeProblems(msg.DriverType, msg.ConfigJson);
|
||||
if (credentialProblems.Count > 0)
|
||||
{
|
||||
replyTo.Tell(new TestDriverConnectResult(
|
||||
false,
|
||||
"Test Connect refused: " + string.Join(" ", credentialProblems),
|
||||
null,
|
||||
msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
|
||||
Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo);
|
||||
}
|
||||
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>lmxopcua#815: the credential-reference namespace deploy gate (Galaxy, OpcUaClient).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorCredentialReferenceTests
|
||||
{
|
||||
private static IReadOnlyList<ValidationError> Validate(string type, string driverConfig, string deviceConfig = "{}") =>
|
||||
DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "drv", ClusterId = "c", Name = "drv", DriverType = type, DriverConfig = driverConfig }],
|
||||
Devices = [new Device { DeviceId = "dev", DriverInstanceId = "drv", Name = "dev1", DeviceConfig = deviceConfig }],
|
||||
Tags = [],
|
||||
}).Where(e => e.Code == CredentialReferenceDeployChecks.Code).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData("GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
|
||||
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"file:C:\\otopcua\\appsettings.json"}}""")]
|
||||
[InlineData("GalaxyMxGateway", """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")]
|
||||
[InlineData("OpcUaClient", """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData("OpcUaClient", """{"UserCertificatePassword":"env:ConnectionStrings__ConfigDb"}""")]
|
||||
public void An_out_of_namespace_reference_is_refused_naming_the_allowed_form(string type, string config)
|
||||
{
|
||||
var e = Validate(type, config).ShouldHaveSingleItem();
|
||||
e.Context.ShouldBe("drv");
|
||||
e.Message.ShouldContain("secret:");
|
||||
e.Message.ShouldContain("lmxopcua#815");
|
||||
e.Message.ShouldContain("Migrate it before deploying");
|
||||
e.Message.ShouldNotContain("ConnectionStrings__ConfigDb");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reference_in_a_device_config_is_checked_too()
|
||||
=> Validate("GalaxyMxGateway", "{}", """{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")
|
||||
.ShouldHaveSingleItem().Context.ShouldBe("dev");
|
||||
|
||||
[Theory]
|
||||
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")]
|
||||
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""")]
|
||||
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"dev:k"}}""")]
|
||||
[InlineData("OpcUaClient", """{"Password":"literal","UserCertificatePassword":"env:OTOPCUA_OPCUACLIENT_CERT_PW"}""")]
|
||||
[InlineData("Modbus", """{"Password":"env:ConnectionStrings__ConfigDb"}""")]
|
||||
public void Namespaced_literal_and_unrelated_configs_pass(string type, string config)
|
||||
=> Validate(type, config).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Heidenhain_is_reported_once_by_its_own_gate_not_twice()
|
||||
=> DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = "Heidenhain", DriverConfig = """{"Access":{"InspectPassword":"env:ConnectionStrings__ConfigDb"}}""" }],
|
||||
Devices = [], Tags = [],
|
||||
})
|
||||
.Count(e => e.Code is CredentialReferenceDeployChecks.Code or HeidenhainDeployChecks.CredentialCode).ShouldBe(1);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>lmxopcua#815 — the shared credential-reference parser, resolver and per-driver policy table.</summary>
|
||||
public sealed class CredentialReferencesTests
|
||||
{
|
||||
private static readonly CredentialScope Galaxy = DriverCredentialPolicy.GalaxyScope;
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, CredentialReferenceKind.Blank)]
|
||||
[InlineData("", CredentialReferenceKind.Blank)]
|
||||
[InlineData("secret:galaxy/inst/apikey", CredentialReferenceKind.Secret)]
|
||||
[InlineData("${secret:galaxy/inst/apikey}", CredentialReferenceKind.Secret)]
|
||||
[InlineData("secret:otopcua/galaxy/ack-fallback", CredentialReferenceKind.Secret)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_API_KEY", CredentialReferenceKind.Environment)]
|
||||
[InlineData("env:GALAXY_MXGW_API_KEY", CredentialReferenceKind.Environment)]
|
||||
[InlineData("plain-key", CredentialReferenceKind.Literal)]
|
||||
[InlineData("dev:k", CredentialReferenceKind.Literal)]
|
||||
[InlineData("secret:otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("secret:otopcua/apikeys/ab/galaxy-ack-password", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("secret:OT-MESH-PSK-MAIN", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("secret:galaxy/", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("secret:galaxy/../otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("secret:galaxy//x", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("secret:galaxyx/y", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("${secret:galaxy/x", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("SECRET:galaxy/x", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("Env:OTOPCUA_GALAXY_X", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("env:ServerHistorian__ApiKey", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("env:OTOPCUA_GALAXY_lower", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("env:OTOPCUA_HEIDENHAIN_X", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("file:appsettings.json", CredentialReferenceKind.OutOfScope)]
|
||||
[InlineData("FILE:x", CredentialReferenceKind.OutOfScope)]
|
||||
public void Parse_classifies_against_the_galaxy_scope(string? value, CredentialReferenceKind expected) =>
|
||||
CredentialReferences.Parse(value, Galaxy, out _).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Scopes_do_not_overlap_across_drivers()
|
||||
{
|
||||
CredentialReferences.Parse("secret:heidenhain/x", Galaxy, out _).ShouldBe(CredentialReferenceKind.OutOfScope);
|
||||
CredentialReferences.Parse("secret:galaxy/x", DriverCredentialPolicy.OpcUaClientScope, out _)
|
||||
.ShouldBe(CredentialReferenceKind.OutOfScope);
|
||||
CredentialReferences.Parse("env:OTOPCUA_GALAXY_X", DriverCredentialPolicy.HeidenhainScope, out _)
|
||||
.ShouldBe(CredentialReferenceKind.OutOfScope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CredentialLiteralPolicy.Allowed, "plain", true)]
|
||||
[InlineData(CredentialLiteralPolicy.Refused, "plain", false)]
|
||||
[InlineData(CredentialLiteralPolicy.Refused, "dev:x", false)]
|
||||
[InlineData(CredentialLiteralPolicy.DevPrefixOnly, "dev:x", true)]
|
||||
[InlineData(CredentialLiteralPolicy.DevPrefixOnly, "plain", false)]
|
||||
[InlineData(CredentialLiteralPolicy.Allowed, "env:ConnectionStrings__ConfigDb", false)]
|
||||
public void IsAcceptable_applies_the_literal_policy(CredentialLiteralPolicy policy, string value, bool expected) =>
|
||||
CredentialReferences.IsAcceptable(value, Galaxy, policy).ShouldBe(expected);
|
||||
|
||||
/// <summary>The fail-closed core: an out-of-scope reference throws before either source is consulted.</summary>
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("secret:otopcua/historian/api-key")]
|
||||
[InlineData("file:appsettings.json")]
|
||||
public async Task Resolve_never_reads_for_an_out_of_scope_reference(string value)
|
||||
{
|
||||
var reads = 0;
|
||||
var ex = await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
|
||||
value, Galaxy, "F", CredentialLiteralPolicy.Allowed,
|
||||
(_, _) => { reads++; return ValueTask.FromResult<string?>("LEAKED"); },
|
||||
_ => { reads++; return "LEAKED"; },
|
||||
CancellationToken.None));
|
||||
reads.ShouldBe(0);
|
||||
ex.Message.ShouldContain("'secret:galaxy/<name>'");
|
||||
ex.Message.ShouldContain("'env:OTOPCUA_GALAXY_<NAME>'");
|
||||
ex.Message.ShouldNotContain("LEAKED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_reads_scoped_references_and_fails_closed_when_absent()
|
||||
{
|
||||
(await CredentialReferences.ResolveAsync("secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused,
|
||||
(n, _) => ValueTask.FromResult<string?>(n == "galaxy/a" ? "v" : null), _ => null, CancellationToken.None))
|
||||
.ShouldBe("v");
|
||||
(await CredentialReferences.ResolveAsync("env:OTOPCUA_GALAXY_A", Galaxy, "F", CredentialLiteralPolicy.Refused,
|
||||
null, n => n == "OTOPCUA_GALAXY_A" ? "e" : null, CancellationToken.None)).ShouldBe("e");
|
||||
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
|
||||
"secret:galaxy/missing", Galaxy, "F", CredentialLiteralPolicy.Refused,
|
||||
(_, _) => ValueTask.FromResult<string?>(null), null, CancellationToken.None))).Message.ShouldContain("absent");
|
||||
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
|
||||
"env:OTOPCUA_GALAXY_UNSET", Galaxy, "F", CredentialLiteralPolicy.Refused,
|
||||
null, _ => null, CancellationToken.None))).Message.ShouldContain("not set");
|
||||
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
|
||||
"secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused,
|
||||
null, null, CancellationToken.None))).Message.ShouldContain("no secret store");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeProblems_scans_every_depth_case_insensitively_and_never_echoes_the_value()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"gateway": { "endpoint": "http://evil:5001", "apiKeySecretRef": "env:ConnectionStrings__ConfigDb" },
|
||||
"Devices": [ { "AlarmAck": { "FallbackPasswordSecretRef": "hunter2" } } ]
|
||||
}
|
||||
""";
|
||||
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, json);
|
||||
problems.Count.ShouldBe(2);
|
||||
problems.ShouldContain(p => p.StartsWith("gateway.apiKeySecretRef", StringComparison.Ordinal));
|
||||
problems.ShouldContain(p => p.StartsWith("Devices[0].AlarmAck.FallbackPasswordSecretRef", StringComparison.Ordinal));
|
||||
problems.ShouldAllBe(p => !p.Contains("hunter2") && !p.Contains("ConnectionStrings__ConfigDb"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"dev:x"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"literal-key"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"plain","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
|
||||
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"env:OTOPCUA_HEIDENHAIN_INSPECT"}}""")]
|
||||
[InlineData(DriverTypeNames.Modbus, """{"Password":"env:ConnectionStrings__ConfigDb"}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, "")]
|
||||
public void DescribeProblems_accepts_namespaced_and_unrelated_configs(string driverType, string json) =>
|
||||
DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"secret:galaxy/main/apikey"}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:C:\\ProgramData\\x.txt"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":42}}""")]
|
||||
[InlineData(DriverTypeNames.Heidenhain, """{"InspectPassword":"plain"}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, "{not json")]
|
||||
public void DescribeProblems_refuses_out_of_namespace_configs(string driverType, string json) =>
|
||||
DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldNotBeEmpty();
|
||||
}
|
||||
+17
@@ -54,6 +54,23 @@ public sealed class GalaxyDriverBrowserTests
|
||||
ex.Message.ShouldContain("ClientName");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: the browser runs on the admin node against an operator-chosen endpoint with no deploy, so it
|
||||
/// never gets the running driver's warn-only compatibility — a legacy ref (the old form default
|
||||
/// <c>env:MX_API_KEY</c>, a <c>file:</c> path, another subsystem's secret) is refused before any connect.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("env:MX_API_KEY")]
|
||||
[InlineData("file:appsettings.json")]
|
||||
[InlineData("secret:otopcua/historian/api-key")]
|
||||
public async Task OpenAsync_refuses_an_out_of_namespace_api_key_ref_with_no_warn_only_fallback(string reference)
|
||||
{
|
||||
var json = "{\"Gateway\":{\"Endpoint\":\"http://127.0.0.1:1\",\"ApiKeySecretRef\":\"" + reference
|
||||
+ "\"},\"MxAccess\":{\"ClientName\":\"X\"},\"Repository\":{},\"Reconnect\":{}}";
|
||||
await Should.ThrowAsync<ZB.MOM.WW.OtOpcUa.Core.Abstractions.CredentialReferenceException>(
|
||||
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>A JSON literal that deserializes to null must fail fast with a
|
||||
/// "deserialized to null" message — never a downstream NRE.</summary>
|
||||
[Fact]
|
||||
|
||||
+70
-41
@@ -1,14 +1,15 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
|
||||
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||
/// Follow-up #2 + G-2a + lmxopcua#815 — pins the resolution forms supported by
|
||||
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:OTOPCUA_GALAXY_NAME</c> (<c>file:</c> is refused),
|
||||
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
||||
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
||||
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
||||
@@ -38,7 +39,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
[Fact]
|
||||
public async Task Env_prefix_resolves_to_environment_variable()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
||||
const string name = "OTOPCUA_GALAXY_TEST_API_KEY";
|
||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||
try
|
||||
{
|
||||
@@ -55,41 +56,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
[Fact]
|
||||
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
||||
const string name = "OTOPCUA_GALAXY_TEST_API_KEY_UNSET";
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
||||
ex.Message.ShouldContain(name);
|
||||
ex.Message.ShouldContain("unset");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
||||
[Fact]
|
||||
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
|
||||
{
|
||||
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver(), ct: TestContext.Current.CancellationToken))
|
||||
.ShouldBe("key-from-file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
||||
[Fact]
|
||||
public async Task File_prefix_missing_path_throws()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||
ex.Message.ShouldContain(path);
|
||||
ex.Message.ShouldContain("doesn't exist");
|
||||
ex.Message.ShouldContain("not set");
|
||||
}
|
||||
|
||||
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
|
||||
@@ -161,7 +134,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
[Fact]
|
||||
public async Task Env_prefix_does_not_emit_literal_warning()
|
||||
{
|
||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
||||
const string name = "OTOPCUA_GALAXY_TEST_API_KEY_NOWARN";
|
||||
Environment.SetEnvironmentVariable(name, "v");
|
||||
try
|
||||
{
|
||||
@@ -211,21 +184,77 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
||||
: null);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
||||
[Fact]
|
||||
public async Task File_prefix_empty_file_throws()
|
||||
// ===== lmxopcua#815: references are confined to the Galaxy namespace, checked before any read =====
|
||||
|
||||
/// <summary>
|
||||
/// An out-of-namespace reference — another subsystem's secret or process variable — throws, and neither the
|
||||
/// store nor the environment is consulted (the value is never read, so it can never be sent).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("env:ServerHistorian__ApiKey")]
|
||||
[InlineData("env:OTOPCUA_GALAXY_lower")]
|
||||
[InlineData("secret:otopcua/historian/api-key")]
|
||||
[InlineData("secret:OT-MESH-PSK-MAIN")]
|
||||
[InlineData("secret:galaxy/../otopcua/historian/api-key")]
|
||||
[InlineData("${secret:otopcua/apikeys/abc/galaxy-ack-password}")]
|
||||
[InlineData("SECRET:galaxy/inst1/apikey")]
|
||||
[InlineData("file:/tmp/any-file")]
|
||||
[InlineData("file:appsettings.json")]
|
||||
public async Task Out_of_namespace_reference_is_refused_without_reading_store_or_environment(string reference)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, " \n ");
|
||||
var store = new CountingResolver();
|
||||
var envReads = 0;
|
||||
var ex = await Should.ThrowAsync<CredentialReferenceException>(() => GalaxySecretRef.ResolveApiKeyAsync(
|
||||
reference, store, ct: TestContext.Current.CancellationToken, environment: _ => { envReads++; return "LEAKED"; }));
|
||||
ex.Message.ShouldContain("secret:galaxy/<name>");
|
||||
ex.Message.ShouldContain("env:OTOPCUA_GALAXY_<NAME>");
|
||||
ex.Message.ShouldNotContain("LEAKED");
|
||||
store.Calls.ShouldBe(0);
|
||||
envReads.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>The namespaced environment forms (incl. the legacy GALAXY_MXGW_ variable) resolve.</summary>
|
||||
[Theory]
|
||||
[InlineData("env:OTOPCUA_GALAXY_API_KEY", "OTOPCUA_GALAXY_API_KEY")]
|
||||
[InlineData("env:GALAXY_MXGW_API_KEY", "GALAXY_MXGW_API_KEY")]
|
||||
public async Task Namespaced_environment_reference_resolves(string reference, string variable)
|
||||
{
|
||||
var key = await GalaxySecretRef.ResolveApiKeyAsync(
|
||||
reference, FakeResolver(), ct: TestContext.Current.CancellationToken,
|
||||
environment: n => n == variable ? "the-key" : null);
|
||||
key.ShouldBe("the-key");
|
||||
}
|
||||
|
||||
/// <summary>A file reference is refused even when the file exists — its contents are never returned.</summary>
|
||||
[Fact]
|
||||
public async Task File_reference_is_refused_even_when_the_file_exists()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, "key-from-file");
|
||||
try
|
||||
{
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
|
||||
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||
ex.Message.ShouldContain("empty");
|
||||
ex.Message.ShouldNotContain("key-from-file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Counts store lookups.</summary>
|
||||
private sealed class CountingResolver : ISecretResolver
|
||||
{
|
||||
/// <summary>Gets the number of lookups.</summary>
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult<string?>("LEAKED");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815 warn-only compatibility (deprecated): an ALREADY-DEPLOYED Galaxy driver whose config predates the
|
||||
/// namespaces — e.g. the old AdminUI form default <c>env:MX_API_KEY</c> — keeps resolving it on init and on every
|
||||
/// gateway reconnect (<c>ReopenAsync</c> → <c>BuildClientOptionsAsync</c>), warning rate-limited and surfacing it
|
||||
/// on the diagnostics bag; while the admin-node paths (browser) stay hard-fail.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverLegacyCredentialTests
|
||||
{
|
||||
private const string LegacyVariable = "MX_API_KEY_815_TEST";
|
||||
|
||||
private static GalaxyDriverOptions Opts(string apiKeyRef, string? ackRef = null) => new(
|
||||
new GalaxyGatewayOptions("https://mxgw.test:5001", apiKeyRef),
|
||||
new GalaxyMxAccessOptions("Compat815"),
|
||||
new GalaxyRepositoryOptions(WatchDeployEvents: false),
|
||||
new GalaxyReconnectOptions())
|
||||
{
|
||||
AlarmAck = ackRef is null ? new GalaxyAlarmAckOptions() : new GalaxyAlarmAckOptions("ack-svc", null, ackRef),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task A_running_driver_with_a_legacy_env_ref_resolves_on_init_and_every_reconnect_and_warns_once()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(LegacyVariable, "the-real-key");
|
||||
try
|
||||
{
|
||||
var logger = new CaptureLogger();
|
||||
var driver = new GalaxyDriver("gx-legacy", Opts($"env:{LegacyVariable}"), new NullResolver(), logger);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// InitializeAsync and each ReopenAsync (gateway reconnect) build the client options through this one method.
|
||||
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
|
||||
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
|
||||
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
|
||||
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("lmxopcua#815")).ToList();
|
||||
warnings.Count.ShouldBe(1); // rate-limited: one per field per hour, not one per reconnect
|
||||
warnings[0].Message.ShouldContain("Gateway.ApiKeySecretRef");
|
||||
warnings[0].Message.ShouldContain("env:OTOPCUA_GALAXY_<NAME>");
|
||||
warnings[0].Message.ShouldNotContain("the-real-key");
|
||||
|
||||
var diagnostics = ((IDriverDiagnostics)driver).GetDiagnostics();
|
||||
diagnostics.Keys.ShouldContain(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef");
|
||||
diagnostics.Values.ShouldAllBe(v => !v.Contains("the-real-key"));
|
||||
DriverDiagnostics.Bound(diagnostics).Values.ShouldAllBe(v => v.Length <= DriverDiagnostics.MaxValueLength);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(LegacyVariable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_running_driver_keeps_the_old_case_insensitive_and_file_semantics()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"gx-815-{Guid.NewGuid():N}.txt");
|
||||
File.WriteAllText(path, " key-from-file \n");
|
||||
try
|
||||
{
|
||||
var driver = new GalaxyDriver("gx-legacy-file", Opts($"file:{path}"), new NullResolver());
|
||||
(await driver.BuildClientOptionsAsync(Opts($"file:{path}").Gateway, TestContext.Current.CancellationToken))
|
||||
.ApiKey.ShouldBe("key-from-file");
|
||||
((IDriverDiagnostics)driver).GetDiagnostics().ShouldNotBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_namespaced_ref_resolves_without_a_compatibility_warning()
|
||||
{
|
||||
var logger = new CaptureLogger();
|
||||
var driver = new GalaxyDriver("gx-ok", Opts("dev:k"), new NullResolver(), logger);
|
||||
(await driver.BuildClientOptionsAsync(Opts("dev:k").Gateway, TestContext.Current.CancellationToken)).ApiKey.ShouldBe("k");
|
||||
logger.Entries.ShouldNotContain(e => e.Message.Contains("lmxopcua#815"));
|
||||
((IDriverDiagnostics)driver).GetDiagnostics().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The same legacy ref through the admin-node browser path (no tracker) is refused before any read.</summary>
|
||||
[Fact]
|
||||
public async Task The_same_ref_without_a_tracker_is_refused()
|
||||
{
|
||||
var reads = 0;
|
||||
await Should.ThrowAsync<CredentialReferenceException>(() => GalaxySecretRef.ResolveApiKeyAsync(
|
||||
"env:MX_API_KEY", new NullResolver(), ct: TestContext.Current.CancellationToken,
|
||||
environment: _ => { reads++; return "the-real-key"; }));
|
||||
reads.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_running_ack_fallback_keeps_a_legacy_ref_and_warns()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add);
|
||||
var resolver = new GalaxyAckCredentialResolver(
|
||||
"gx", new GalaxyAlarmAckOptions("ack-svc", null, "env:LEGACY_ACK_PW"), new NullResolver(), new CaptureLogger(),
|
||||
n => n == "LEGACY_ACK_PW" ? "pw" : null, tracker);
|
||||
|
||||
(await resolver.ResolveAsync(null, TestContext.Current.CancellationToken)).Credential.ShouldNotBeNull().Password.ShouldBe("pw");
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("AlarmAck.FallbackPasswordSecretRef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tracker_re_warns_after_the_interval()
|
||||
{
|
||||
var now = new DateTime(2026, 11, 11, 0, 0, 0, DateTimeKind.Utc);
|
||||
var warnings = new List<string>();
|
||||
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add, () => now);
|
||||
tracker.Report("F");
|
||||
tracker.Report("F");
|
||||
now += LegacyCredentialReferenceTracker.WarnInterval;
|
||||
tracker.Report("F");
|
||||
tracker.Report("G");
|
||||
warnings.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
private sealed class NullResolver : ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
private sealed class CaptureLogger : ILogger<GalaxyDriver>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -300,6 +300,48 @@ public sealed class GalaxyAckCredentialTests
|
||||
.Password.ShouldBe(FallbackPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: the fallback password is sent to the operator-authored gateway endpoint, so a reference
|
||||
/// outside the Galaxy namespace is refused — the store/environment value is never read, never sent.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("secret:otopcua/apikeys/0a1b/galaxy-ack-password")]
|
||||
[InlineData("${secret:otopcua/historian/api-key}")]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("file:appsettings.json")]
|
||||
public async Task Resolver_refuses_a_fallback_reference_outside_the_galaxy_namespace(string reference)
|
||||
{
|
||||
const string Stolen = "stolen-value";
|
||||
var secrets = new DictSecretResolver
|
||||
{
|
||||
["otopcua/apikeys/0a1b/galaxy-ack-password"] = Stolen,
|
||||
["otopcua/historian/api-key"] = Stolen,
|
||||
};
|
||||
var envReads = 0;
|
||||
var logger = new CapturingLogger();
|
||||
var resolver = new GalaxyAckCredentialResolver(
|
||||
"drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, logger,
|
||||
_ => { envReads++; return Stolen; });
|
||||
|
||||
var result = await resolver.ResolveAsync(null, CancellationToken.None);
|
||||
|
||||
result.Credential.ShouldBeNull();
|
||||
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(Stolen);
|
||||
logger.AllText.ShouldNotContain(Stolen);
|
||||
envReads.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolver_resolves_a_namespaced_environment_fallback()
|
||||
{
|
||||
var resolver = new GalaxyAckCredentialResolver(
|
||||
"drv", new GalaxyAlarmAckOptions("ack-svc", null, "env:OTOPCUA_GALAXY_ACK_PASSWORD"), new DictSecretResolver(),
|
||||
new CapturingLogger(), n => n == "OTOPCUA_GALAXY_ACK_PASSWORD" ? FallbackPassword : null);
|
||||
|
||||
(await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull()
|
||||
.Password.ShouldBe(FallbackPassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw()
|
||||
{
|
||||
|
||||
+3
-3
@@ -56,11 +56,11 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = path,
|
||||
UserCertificatePassword = "secret:opcua/browse-cert",
|
||||
UserCertificatePassword = "secret:opcuaclient/browse-cert",
|
||||
};
|
||||
|
||||
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
||||
opts, new OneSecret("opcua/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
|
||||
opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
|
||||
|
||||
identity.TokenType.ShouldBe(UserTokenType.Certificate);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
{
|
||||
AuthType = OpcUaAuthType.Username,
|
||||
Username = "op",
|
||||
Password = "secret:opcua/pw",
|
||||
Password = "secret:opcuaclient/pw",
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
|
||||
+88
-4
@@ -1,5 +1,6 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
@@ -16,9 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientSecretResolutionTests
|
||||
{
|
||||
private const string KnownPwName = "opcua/inst/password";
|
||||
private const string KnownPwName = "opcuaclient/inst/password";
|
||||
private const string KnownPwValue = "resolved-password-plaintext";
|
||||
private const string KnownCertPwName = "opcua/inst/cert-pw";
|
||||
private const string KnownCertPwName = "opcuaclient/inst/cert-pw";
|
||||
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
||||
|
||||
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||
@@ -70,14 +71,97 @@ public sealed class OpcUaClientSecretResolutionTests
|
||||
[Fact]
|
||||
public async Task Unknown_secret_ref_is_fail_closed()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
|
||||
var options = new OpcUaClientDriverOptions { Password = "secret:opcuaclient/inst/absent" };
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
options, FakeResolver(), CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("Password");
|
||||
ex.Message.ShouldContain("opcua/inst/absent");
|
||||
ex.Message.ShouldContain("opcuaclient/inst/absent");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: the password is sent to the operator-authored endpoint, so a reference outside the
|
||||
/// OpcUaClient namespace throws BEFORE the store or the environment is read — it is never resolved, never sent.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("secret:otopcua/historian/api-key")]
|
||||
[InlineData("secret:galaxy/inst1/apikey")]
|
||||
[InlineData("secret:OT-MESH-PSK-MAIN")]
|
||||
[InlineData("env:ConnectionStrings__ConfigDb")]
|
||||
[InlineData("env:ServerHistorian__ApiKey")]
|
||||
[InlineData("Secret:opcuaclient/inst/password")]
|
||||
[InlineData("file:appsettings.json")]
|
||||
public async Task Out_of_namespace_reference_is_refused_without_reading(string reference)
|
||||
{
|
||||
var store = new CountingResolver();
|
||||
var envReads = 0;
|
||||
foreach (var options in new[]
|
||||
{
|
||||
new OpcUaClientDriverOptions { Password = reference },
|
||||
new OpcUaClientDriverOptions { UserCertificatePassword = reference },
|
||||
})
|
||||
{
|
||||
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
|
||||
OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
options, store, CancellationToken.None, _ => { envReads++; return "LEAKED"; }));
|
||||
ex.Message.ShouldContain("secret:opcuaclient/<name>");
|
||||
ex.Message.ShouldNotContain("LEAKED");
|
||||
}
|
||||
|
||||
store.Calls.ShouldBe(0);
|
||||
envReads.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A namespaced environment reference resolves.</summary>
|
||||
[Fact]
|
||||
public async Task Namespaced_environment_reference_resolves()
|
||||
{
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
new OpcUaClientDriverOptions { Password = "env:OTOPCUA_OPCUACLIENT_PLANT_PW" }, FakeResolver(),
|
||||
CancellationToken.None, n => n == "OTOPCUA_OPCUACLIENT_PLANT_PW" ? "pw" : null);
|
||||
resolved.Password.ShouldBe("pw");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815 warn-only compatibility (deprecated): the RUNNING driver's session open keeps the old semantics
|
||||
/// for an out-of-namespace value — a case-insensitive <c>secret:</c> resolves, anything else is a literal — and
|
||||
/// warns once; the browser (no tracker) refuses the same value (see the theory above).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_running_driver_keeps_old_semantics_for_a_legacy_value_and_warns()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add);
|
||||
var legacyOptions = new OpcUaClientDriverOptions
|
||||
{
|
||||
Password = $"SECRET:{KnownPwName}", // pre-#815: case-insensitive secret: resolved
|
||||
UserCertificatePassword = "env:NOT_A_REF", // pre-#815: only secret: resolved — a literal password
|
||||
};
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
||||
legacyOptions, FakeResolver(), CancellationToken.None, tracker);
|
||||
resolved.Password.ShouldBe(KnownPwValue);
|
||||
resolved.UserCertificatePassword.ShouldBe("env:NOT_A_REF");
|
||||
}
|
||||
|
||||
warnings.Count.ShouldBe(2); // one per field, rate-limited across the three session opens
|
||||
warnings.ShouldAllBe(w => !w.Contains(KnownPwValue));
|
||||
tracker.Diagnostics.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
private sealed class CountingResolver : ISecretResolver
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult<string?>("LEAKED");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: the AdminUI live browse (Galaxy / OpcUaClient bespoke browsers, and the Discover-backed universal
|
||||
/// browser) resolves the config's credential references and sends them to the author-chosen endpoint — an
|
||||
/// out-of-namespace reference is refused before any browser is opened.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BrowserSessionCredentialScopeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"}}""")]
|
||||
public async Task Out_of_namespace_reference_never_opens_a_browser(string driverType, string config)
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var bespoke2 = new RecordingBrowser(DriverTypeNames.OpcUaClient);
|
||||
var universal = new RecordingUniversal();
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke, bespoke2], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, universal, null!, null!);
|
||||
|
||||
var result = await svc.OpenAsync(driverType, config, TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#815");
|
||||
bespoke.Opens.ShouldBe(0);
|
||||
bespoke2.Opens.ShouldBe(0);
|
||||
universal.Opens.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a namespaced reference reaches the browser.</summary>
|
||||
[Fact]
|
||||
public async Task A_namespaced_reference_reaches_the_browser()
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!);
|
||||
|
||||
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"}}""",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
bespoke.Opens.ShouldBe(1);
|
||||
}
|
||||
|
||||
private sealed class RecordingBrowser(string driverType) : IDriverBrowser
|
||||
{
|
||||
public int Opens { get; private set; }
|
||||
|
||||
public string DriverType => driverType;
|
||||
|
||||
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Opens++;
|
||||
throw new InvalidOperationException("opened");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingUniversal : IUniversalDriverBrowser
|
||||
{
|
||||
public int Opens { get; private set; }
|
||||
|
||||
public bool CanBrowse(string driverType, string configJson) => true;
|
||||
|
||||
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||
{
|
||||
Opens++;
|
||||
throw new InvalidOperationException("opened");
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815 upgrade preflight: stored (draft) and deployed configs with out-of-namespace credential references
|
||||
/// are listed and logged — driver, device, field, allowed form — and never the value; nothing is resolved.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialReferencePreflightTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private const string LegacyGalaxy = """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""";
|
||||
|
||||
private static string SeedLegacy()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
// Written straight to the DB, as a pre-#815 build would have — the save gate now refuses it.
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "gx-old", ClusterId = RawTreeTestDb.ClusterId, Name = "Galaxy main",
|
||||
DriverType = DriverTypeNames.Galaxy, DriverConfig = LegacyGalaxy,
|
||||
});
|
||||
db.Devices.Add(new Device
|
||||
{
|
||||
DeviceId = "ouc-dev", DriverInstanceId = "gx-old", Name = "gw1",
|
||||
DeviceConfig = """{"AlarmAck":{"FallbackPasswordSecretRef":"file:C:\\keys\\ack.txt"}}""",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "mb", ClusterId = RawTreeTestDb.ClusterId, Name = "modbus",
|
||||
DriverType = DriverTypeNames.Modbus, DriverConfig = """{"Password":"env:ConnectionStrings__ConfigDb"}""",
|
||||
});
|
||||
var artifact = """
|
||||
{"DriverInstances":[{"DriverInstanceId":"ouc","Name":"Upstream","DriverType":"OpcUaClient",
|
||||
"DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]}
|
||||
""";
|
||||
db.Deployments.Add(new Deployment { RevisionHash = "abc", CreatedBy = "t", ArtifactBlob = Encoding.UTF8.GetBytes(artifact) });
|
||||
db.SaveChanges();
|
||||
return name;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Scan_lists_draft_rows_devices_and_the_deployed_artifact_without_values()
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(RawTreeTestDb.Factory(SeedLegacy())).ScanAsync(Ct);
|
||||
|
||||
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DriverName == "Galaxy main"
|
||||
&& f.DeviceName == null && f.Problem.Contains("Gateway.ApiKeySecretRef")
|
||||
&& f.Problem.Contains("env:OTOPCUA_GALAXY_<NAME>"));
|
||||
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DeviceName == "gw1"
|
||||
&& f.Problem.Contains("AlarmAck.FallbackPasswordSecretRef"));
|
||||
findings.ShouldContain(f => f.Source == CredentialFindingSource.Deployed && f.DriverName == "Upstream"
|
||||
&& f.Problem.Contains("secret:opcuaclient/<name>"));
|
||||
findings.ShouldNotContain(f => f.DriverInstanceId == "mb"); // not a credential-resolving driver
|
||||
findings.ShouldAllBe(f => !f.Problem.Contains("MX_API_KEY") && !f.Problem.Contains("plant/opcua-pw")
|
||||
&& !f.Problem.Contains("ack.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_clean_database_has_no_findings()
|
||||
=> (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public async Task The_boot_pass_logs_one_warning_per_finding_and_publishes_the_state()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(RawTreeTestDb.Factory(SeedLegacy()))
|
||||
.BuildServiceProvider();
|
||||
var state = new CredentialReferencePreflightState();
|
||||
var logger = new CaptureLogger();
|
||||
var svc = new CredentialReferencePreflightService(services, new FakeLifetime(), state, logger) { StartDelay = TimeSpan.Zero };
|
||||
|
||||
var findings = await svc.RunOnceAsync(Ct);
|
||||
|
||||
findings.Count.ShouldBe(3);
|
||||
state.Current.Findings.Count.ShouldBe(3);
|
||||
state.Current.ScannedAtUtc.ShouldNotBeNull();
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
warnings.Count.ShouldBe(3);
|
||||
warnings.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY"));
|
||||
warnings.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_without_a_config_db_skips_quietly()
|
||||
{
|
||||
var svc = new CredentialReferencePreflightService(
|
||||
new ServiceCollection().BuildServiceProvider(), new FakeLifetime(), new CredentialReferencePreflightState(), new CaptureLogger());
|
||||
(await svc.RunOnceAsync(Ct)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_hosted_service_waits_for_ApplicationStarted_before_scanning()
|
||||
{
|
||||
var services = new ServiceCollection().AddSingleton(RawTreeTestDb.Factory(SeedLegacy())).BuildServiceProvider();
|
||||
var lifetime = new FakeLifetime();
|
||||
var state = new CredentialReferencePreflightState();
|
||||
var svc = new CredentialReferencePreflightService(services, lifetime, state, new CaptureLogger()) { StartDelay = TimeSpan.Zero };
|
||||
|
||||
await svc.StartAsync(Ct);
|
||||
await Task.Delay(100, Ct);
|
||||
state.Current.ScannedAtUtc.ShouldBeNull(); // nothing before the host has started
|
||||
|
||||
lifetime.Start();
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (state.Current.ScannedAtUtc is null && DateTime.UtcNow < deadline) await Task.Delay(20, Ct);
|
||||
state.Current.Findings.Count.ShouldBe(3);
|
||||
await svc.StopAsync(Ct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Running_drivers_reporting_warn_only_resolution_are_surfaced_from_driver_health()
|
||||
{
|
||||
var snapshots = new[]
|
||||
{
|
||||
new DriverHealthChanged("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow,
|
||||
Diagnostics: new Dictionary<string, string>
|
||||
{
|
||||
[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated …",
|
||||
["s7.pdu"] = "480",
|
||||
}),
|
||||
new DriverHealthChanged("c", "mb", "Healthy", null, null, 0, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var running = CredentialReferencePreflight.RunningFindings(snapshots);
|
||||
|
||||
running.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
|
||||
f => f.Source.ShouldBe(CredentialFindingSource.Running),
|
||||
f => f.DriverInstanceId.ShouldBe("gx-old"),
|
||||
f => f.Problem.ShouldStartWith("Gateway.ApiKeySecretRef"));
|
||||
}
|
||||
|
||||
private sealed class FakeLifetime : IHostApplicationLifetime
|
||||
{
|
||||
private readonly CancellationTokenSource _started = new();
|
||||
|
||||
public CancellationToken ApplicationStarted => _started.Token;
|
||||
|
||||
public CancellationToken ApplicationStopping => CancellationToken.None;
|
||||
|
||||
public CancellationToken ApplicationStopped => CancellationToken.None;
|
||||
|
||||
public void Start() => _started.Cancel();
|
||||
|
||||
public void StopApplication()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureLogger : ILogger<CredentialReferencePreflightService>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815 at the SAVE path (RawTreeService): a Galaxy / OpcUaClient credential reference outside the
|
||||
/// driver's namespace never reaches the config DB, through driver and device create + update.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialReferenceSavePathTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:appsettings.json"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
|
||||
public async Task CreateDriver_refuses_an_out_of_namespace_reference_and_persists_nothing(string type, string config)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "d815", type, config, Ct);
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("lmxopcua#815");
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
db.DriverInstances.ShouldNotContain(d => d.Name == "d815");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_paths_refuse_and_keep_the_stored_config()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gx", DriverTypeNames.Galaxy,
|
||||
"""{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/gx/apikey"}}""", Ct);
|
||||
created.Ok.ShouldBeTrue(created.Error);
|
||||
|
||||
byte[] rv;
|
||||
string deviceId;
|
||||
byte[] deviceRv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(name))
|
||||
{
|
||||
rv = db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).RowVersion;
|
||||
var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId);
|
||||
deviceId = device.DeviceId;
|
||||
deviceRv = device.RowVersion;
|
||||
}
|
||||
|
||||
(await svc.UpdateDriverAsync(created.CreatedId!, "gx",
|
||||
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", null, rv, Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.UpdateDeviceAsync(deviceId, "dev",
|
||||
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", true, deviceRv, Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.CreateDeviceAsync(created.CreatedId!, "dev2",
|
||||
"""{"Gateway":{"ApiKeySecretRef":"secret:otopcua/historian/api-key"}}""", Ct)).Ok.ShouldBeFalse();
|
||||
|
||||
using var check = RawTreeTestDb.CreateNamed(name);
|
||||
check.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).DriverConfig.ShouldContain("secret:galaxy/gx/apikey");
|
||||
check.Devices.Where(d => d.DriverInstanceId == created.CreatedId)
|
||||
.ShouldAllBe(d => !d.DeviceConfig.Contains("ConnectionStrings") && !d.DeviceConfig.Contains("historian"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"literal-password","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
|
||||
public async Task Namespaced_references_save(string type, string config)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ok815", type, config, Ct);
|
||||
result.Ok.ShouldBeTrue(result.Error);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any
|
||||
/// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value
|
||||
/// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read".
|
||||
/// </summary>
|
||||
public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
|
||||
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")]
|
||||
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")]
|
||||
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")]
|
||||
public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config)
|
||||
{
|
||||
var probe = new RecordingProbe(driverType);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid()));
|
||||
|
||||
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
|
||||
reply.Message.ShouldContain("lmxopcua#815");
|
||||
probe.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a namespaced reference does reach the probe (the refusal is not blanket).</summary>
|
||||
[Fact]
|
||||
public void A_namespaced_reference_reaches_the_probe()
|
||||
{
|
||||
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
|
||||
|
||||
actor.Tell(new TestDriverConnect(
|
||||
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid()));
|
||||
|
||||
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
|
||||
probe.Calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
private sealed class RecordingProbe(string driverType) : IDriverProbe
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public string DriverType => driverType;
|
||||
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user