Compare commits

..

12 Commits

Author SHA1 Message Date
dohertj2 50426d4790 Merge pull request 'fix(alarms): populate EventType/SourceNode/SourceName on native + scripted conditions (#473)' (#474) from fix/alarm-condition-source-fields into master
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 10m4s
2026-07-17 00:45:51 -04:00
Joseph Doherty 7339a4af07 fix(alarms): populate EventType/SourceNode/SourceName on conditions (#473)
v2-ci / build (pull_request) Successful in 3m38s
v2-ci / unit-tests (pull_request) Failing after 9m38s
MaterialiseAlarmCondition never assigned the three mandatory BaseEventType
identity fields, so all three arrived null on every condition event — native
and scripted. The SDK does not synthesise them on this path: Create() builds
the children from the type definition but leaves them unset, the auto-filling
BaseEventState.Initialize overload is only used for transient events, and
ReportEvent / InstanceStateSnapshot copy children verbatim. A conforming
client could not attribute an alarm to its source.

  EventType  = the concrete materialised type (TypeDefinitionId)
  SourceNode = the condition's own NodeId (== ConditionId) — the condition IS
               the source; an alarm-bearing raw tag materialises only the
               condition, with no sibling value variable
  SourceName = the same identifying id string: RawPath (native) /
               ScriptedAlarmId (scripted)

SourceName carries the unique id rather than the leaf name: the leaf collides
across devices (HR200 on two PLCs) and is already carried by ConditionName, so
nothing is lost. Documented in docs/AlarmTracking.md, including that clients
must key on ConditionId and must not compose SourceName with ConditionName,
and that SourceName is NOT a live<->history join key (the alarm-history writer
stamps it with the EquipmentPath — a pre-existing divergence, now called out).

Tests: NativeAlarmEventIdentityFieldDeliveryTests is the wire-level guard —
a real client subscription using the standard [EventType, SourceNode,
SourceName, Time, Message, Severity] select clause, verified to fail against
the pre-fix server. NodeManagerAlarmSourceFieldsTests guards the node across
both realms, the base-type fallback, and the kind-swap re-materialise.

The HistoryRead events projection is a separate path (it projects historian
rows, not node fields) and is unaffected — its EventType => Variant.Null
assertions still hold.
2026-07-17 00:36:44 -04:00
Joseph Doherty 872cf7e37a test(secrets): register ISecretResolver in driver-factory resilience tests (Task 10 fix)
v2-ci / build (push) Successful in 3m36s
v2-ci / unit-tests (push) Failing after 8m35s
The full-suite gate caught a real regression: AddOtOpcUaDriverFactories' registration
now resolves ISecretResolver (for Galaxy/OpcUaClient secret: refs, Tasks 7-8), so the
two ResilienceInvokerFactoryRegistrationTests that build a minimal container and resolve
the invoker factory threw 'No service for ISecretResolver'. The real host always
registers it via AddZbSecrets (unconditional, Task 3), and GetRequiredService correctly
fails-fast for a genuinely misconfigured host — so the fix is to complete the test
container with a stub resolver, matching production composition. Test-only.
2026-07-16 18:27:59 -04:00
Joseph Doherty f1534920de test(secrets): G-2c guard AdminUI DriverConfig secret: ref round-trip (Task 9)
Verify + regression-guard that the AdminUI driver-config editor persists secret:/
env:/file: refs verbatim and never resolve-then-resaves cleartext (which would
re-leak the secret into the config store, defeating G-2). Confirmed structurally
safe: DriverConfig is persisted as an opaque JSON string via RawTreeService, which
has NO secret-resolver dependency (only the DbContext factory) — resolution is
impossible on the save path. Resolution stays confined to driver-instantiation /
Test-connect (Tasks 7-8); GalaxyDriverBrowser resolves into a connect-scoped local
only, never writing back. Three round-trip tests (OpcUaClient Password/UserCertPassword,
Galaxy ApiKeySecretRef, env:/file: forms) exercise the real RawTreeService seam and
assert byte-identical save→reload. No product change (no leak found). 662 AdminUI tests pass.
2026-07-16 18:19:24 -04:00
Joseph Doherty 9bb237b794 feat(secrets): G-2b resolve OpcUaClient secret: Password/UserCertificatePassword (Task 8)
Resolve secret:-prefixed Password + UserCertificatePassword through the shared
ISecretResolver, fail-closed on absent, retiring the cleartext-in-DB path. The
driver-registry factory is synchronous (Func<string,string,IDriver>), so resolution
is done lazily in the async session-open (InitializeAsync, before BuildUserIdentity)
rather than at deserialize — mirroring Task 7's Galaxy pattern and matching its
re-resolve-on-reconnect behavior. Both consumers (username Password and the
certificate-password LoadPkcs12 path via BuildCertificateIdentity) see the resolved
connect-scoped options; _options stays raw (secret: refs intact), no long-lived
plaintext field.

Scope corrections vs the plan (verified against v3): the probe is unauthenticated
GetEndpoints-only and never reads either credential, so it is NOT a resolution site
(comment added, no dead code); OpcUaClientDriverOptions was a sealed class, converted
to sealed record for the with-expression (no positional params → identical JSON; no
reference-equality/dict-key/ToString-log usages → no behavior/leak change).

ISecretResolver threaded via factory Register/CreateInstance + DriverFactoryBootstrap
(real resolver from DI); NullSecretResolver null-object backs test/parse paths only,
fail-closed on secret: refs. TDD: 4 helper tests RED→GREEN; 142 OpcUaClient tests pass.
2026-07-16 18:14:12 -04:00
Joseph Doherty 1424a21419 feat(secrets): G-2a secret: arm on GalaxySecretRef via ISecretResolver (Task 7)
Add a secret:NAME arm to GalaxySecretRef.ResolveApiKey that resolves the Galaxy
gateway API key through the shared ISecretResolver — fail-closed if the secret is
absent (never falls through to the cleartext literal arm), retiring the dev:/literal
in-DB path for production. Because GetAsync is async the method becomes
ResolveApiKeyAsync; the await cascade threads ISecretResolver by ctor injection into
GalaxyDriver + GalaxyDriverBrowser and (since GalaxyDriver is built by a static
factory closure, not DI) through GalaxyDriverFactoryExtensions + DriverFactoryBootstrap
(which pulls the real resolver from the service provider — registered unconditionally
in Slice 1). A NullSecretResolver null-object backs the parse-only/test paths only;
the runtime path always gets the real resolver (verified end-to-end).

TDD: 3 new secret:-arm tests (resolve / fail-closed-on-absent / no-literal-warning)
RED without the arm, GREEN with it; 338 Galaxy tests pass; no sync-over-async.
2026-07-16 17:57:04 -04:00
Joseph Doherty ce383df39a docs(secrets): G-5 clustered master-key posture runbook (Task 6)
OtOpcUa is Akka-clustered and AddZbSecrets is registered unconditionally, so every
node (admin, driver, fused) resolves secrets and needs the SAME KEK + same store rows.
Ship G-5 as a production-posture runbook rather than hardcoding Source=File + shared
paths into the committed role-overlay appsettings (which dev + TwoNodeClusterHarness
also consume — that would break every dev/CI boot). Base appsettings stays on
Source=Environment. Documents the interim File-KEK + shared-SQLite posture and the
G-7 hand-off (ConfigDb-backed ISecretStore mirroring the existing DP
PersistKeysToDbContext<OtOpcUaConfigDbContext> key-ring sharing). Cross-linked from
docs/security.md. Mirrors the ScadaBridge G-5 resolution.
2026-07-16 17:30:32 -04:00
Joseph Doherty a0be76b5f0 feat(secrets): mount /admin/secrets UI + secrets authorization (Task 5, G-6)
Mount the shared ZB.MOM.WW.Secrets.Ui RCL page at /admin/secrets on admin nodes:
- Register secrets:manage/secrets:reveal policies additively via
  Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization()) in AddAdminUI
  (the admin-only composition layer that already references the RCL — avoids forcing
  an RCL dependency into the core Security lib; mirrors HistorianGateway)
- Register the RCL routable assembly in BOTH the SSR endpoint (AddAdditionalAssemblies)
  and the interactive Router (App.razor AdditionalAssemblies) or the route 404s
- Add a Secrets nav item; the page's own [Authorize(Policy=...)] gates access

Claim-type MATCH: AdminRole=Administrator reads the same ClaimTypes.Role as FleetAdmin,
so existing Administrators are authorized with no new role mapping. Full clean boot
verified; interactive reveal deferred to the live gate (shared UI already proven).
2026-07-16 17:26:54 -04:00
Joseph Doherty 73d8439412 docs(secrets): G-4 ${secret:} config delivery convention + fail-closed proof (Task 4)
OtOpcUa commits no plaintext secrets — the only secret-shaped committed value is
ServerHistorian:ApiKey='' (empty, disabled by default). Because the pre-host expander
is fail-closed AND section-agnostic, hard-committing a ${secret:} token would break
every dev/CI/integration boot for zero at-rest benefit. Instead document the token
delivery convention for all five config secrets (jwt/ldap/deploy/configdb/historian)
in docs/security.md and note the token option in the ServerHistorian comments.

Mechanism proven live: unseeded token boot fails with SecretNotFoundException at
pre-host expansion; seeded token resolves and boot proceeds. Behavior-neutral
(comment + doc only).
2026-07-16 17:14:32 -04:00
Joseph Doherty 8843418c54 feat(secrets): register AddZbSecrets unconditionally on the host (Task 3)
Runtime ISecretResolver must be present on every clustered node regardless of
role: driver-only nodes resolve Layer-B DriverConfig secret: refs but have no
auth/DP/AdminUI (all admin-role-gated). Placed in the unconditional flow next to
AddOtOpcUaHealth(), outside both role blocks. Data Protection left untouched.
2026-07-16 17:07:41 -04:00
Joseph Doherty 772d3a5f34 feat(secrets): add ZB.MOM.WW.Secrets refs + pre-host ${secret:} expander (Tasks 1-2)
Adopt the ZB.MOM.WW.Secrets library (0.1.2, Gitea feed) into OtOpcUa under CPM:
- Directory.Packages.props + NuGet.config source mapping for the three packages
- Host/AdminUI/Driver.Galaxy.Contracts/Driver.OpcUaClient package references
- Layer-A pre-host ${secret:} config expander in Host/Program.cs, placed after all
  config providers assemble and before the first config read (mechanism only,
  behavior-neutral no-op until Task 4 introduces tokens)
- Secrets section in appsettings.json (env-var KEK, SQLite store, 30s cache TTL)

Mechanism mirrors the proven HistorianGateway adoption. KEK never committed.
2026-07-16 17:03:16 -04:00
dohertj2 ec6598ceae Merge pull request 'v3 Batch 4 — dual-namespace address space + raw-only runtime binding (v3.0)' (#472) from v3/batch4-address-space into master
v2-ci / build (push) Successful in 3m59s
v2-ci / unit-tests (push) Failing after 9m25s
2026-07-16 14:38:29 -04:00
40 changed files with 1487 additions and 106 deletions
+3
View File
@@ -132,6 +132,9 @@
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" /> <PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" /> <PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" /> <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
+2
View File
@@ -25,6 +25,8 @@
<package pattern="ZB.MOM.WW.Theme" /> <package pattern="ZB.MOM.WW.Theme" />
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" /> <package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
<package pattern="ZB.MOM.WW.HistorianGateway.Client" /> <package pattern="ZB.MOM.WW.HistorianGateway.Client" />
<package pattern="ZB.MOM.WW.Secrets" />
<package pattern="ZB.MOM.WW.Secrets.*" />
</packageSource> </packageSource>
</packageSourceMapping> </packageSourceMapping>
</configuration> </configuration>
+43
View File
@@ -51,6 +51,49 @@ same condition is wired as an **event notifier of every referencing equipment fo
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
identity RawPath + condition NodeId) with the equipment list as display metadata. identity RawPath + condition NodeId) with the equipment list as display metadata.
## Condition event identity fields (what a client reads on the wire)
Every condition event — native and scripted — carries the mandatory `BaseEventType` identity
fields, assigned at materialize time in `OtOpcUaNodeManager.MaterialiseAlarmCondition`. The SDK
does **not** synthesize them on this path (`Create` builds the children from the type definition
but leaves them unset; `ReportEvent` / `InstanceStateSnapshot` copy children verbatim), so they
are set explicitly. Leaving them unset shipped them as **null** on every event — see issue #473.
| Field | Value | Notes |
|---|---|---|
| `EventType` | the **concrete** materialized type (`TypeDefinitionId`) | e.g. `OffNormalAlarmType`; falls back to `AlarmConditionType` for an unknown authored type. Readable as a *field*, not only via an `OfType` where-clause |
| `SourceNode` | the condition's **own NodeId** — equal to `ConditionId` | The condition **is** the source: an alarm-bearing raw tag materializes only the condition, with no sibling value variable, so there is no other node to point at |
| `SourceName` | the same identifying id string: **RawPath** (native) / **ScriptedAlarmId** (scripted) | Deliberately the *unique* id, **not** the leaf name |
| `ConditionName` | the leaf / display name (e.g. `HR200`) | Where the short human-readable name lives |
**Why `SourceName` is the id, not the leaf name.** The leaf is ambiguous across devices (`HR200` on
two PLCs collides) and is already carried by `ConditionName`, so the leaf-name option would add no
identity while costing uniqueness. Carrying the id makes the `SourceNode`/`SourceName`/`ConditionId`
triple mutually consistent and unique. This diverges from the loose OPC UA convention that
`SourceName` mirrors the source node's BrowseName; the divergence is intentional.
**Client guidance:** key on **`ConditionId`** — the condition node's own NodeId, which equals
`SourceNode`, and whose identifier is the RawPath for native alarms and the ScriptedAlarmId for
scripted ones. It is the identity ack/confirm/shelve route on. `SourceName` carries the same
identifier and is unique, so it is safe to key on *by itself* — but do **not** compose it with
`ConditionName` (`$"{SourceName}.{ConditionName}"`), because `SourceName` already ends in the
condition's leaf name and the result stutters (`pymodbus/plc/HR200.HR200`).
Wire-level guard: `NativeAlarmEventIdentityFieldDeliveryTests` asserts the three fields arrive
populated on a real subscription using the standard `[EventType, SourceNode, SourceName, Time,
Message, Severity]` select clause; `NodeManagerAlarmSourceFieldsTests` guards the node itself
across both realms.
> **Do not correlate live events to HistoryRead on `SourceName` — the two paths disagree.**
> The HistoryRead *events* projection (`OtOpcUaNodeManager.ProjectEventField`) returns
> `Variant.Null` for `EventType` / `SourceNode` **by design**: it projects from the historian's
> `HistoricalEvent` rows, which do not carry them. It **does** project `SourceName` — but the
> alarm-history writer stamps that field with the **EquipmentPath**
> (`AlarmEventMapper`: `SourceName = alarm.EquipmentPath`), not the RawPath a live event carries.
> So `SourceName` is the one field populated on both paths **with different values**, and it is not
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
> live-path fix above does not change the history path.)
## Galaxy driver path (driver-native) ## Galaxy driver path (driver-native)
Restored in PR B.2 of the epic. `GalaxyDriver` implements Restored in PR B.2 of the epic. `GalaxyDriver` implements
@@ -0,0 +1,177 @@
# Secrets: Clustered Master-Key Posture (All Roles)
## Purpose
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) that runs at **every** OtOpcUa node
boot, before the host is built. It reads rows from an envelope-encrypted SQLite
store (`Secrets:SqlitePath`) unwrapped with a key-encryption key (KEK) sourced per
`Secrets:MasterKey:Source`. The runtime `ISecretResolver` (`AddZbSecrets`) is also
registered unconditionally, independent of node role.
OtOpcUa is Akka.NET-clustered (`builder.Services.AddAkka("otopcua", (ab, sp) => { ... })`
in `Program.cs`), with roles parsed by `RoleParser` (`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs`):
`admin`, `driver`, `dev`. A node can carry any combination of these roles (e.g. a
fused admin+driver node, or a driver-only node). This runbook covers what a
production deployment needs so that secret resolution behaves identically on
**every node regardless of role** — not just admin nodes. That matters here more
than it might elsewhere: the pre-host expander and the runtime resolver both run
unconditionally on driver-only nodes too, and driver-only nodes are the ones that
will resolve Layer-B `DriverConfig` secret references (coming in Slice 2) at
runtime, not just at boot. It does **not** change any code; it is an
operations/deployment posture, delivered out-of-band from the committed config.
## The two hard requirements
For the pre-host expander (and the runtime resolver) to resolve the same
plaintext secret on every node, no matter its role:
1. **Identical KEK on every node.** All nodes — admin, driver, and any fused
combination — must unwrap the store with the exact same master key. A
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
decrypt every *other* node's ciphertext rows to garbage.
2. **Identical store rows on every node.** All nodes must read the same SQLite
database (same file, or a replicated/shared copy with the same rows) — not
independently-seeded stores that happen to share a KEK.
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
— there is no built-in cross-node replication today. Meeting both requirements in
production is a deployment concern, covered below.
## Recommended interim posture (G-5)
Until real replication exists (G-7, below), the recommended production posture is:
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
key file that is identical on every node of every role** — a base64-encoded
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
to each node's filesystem/secret-mount by the deployment tooling, and **never
committed** to the repo. Treat it with the same discipline as any other
production secret (restrictive file ACLs, no logging, rotated via a future
KEK-rotation runbook — not yet built).
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
that every node mounts (admin, driver, and dev/fused alike), so every node's
migrator opens and reads the same rows at boot.
Writes to the store are rare and human-driven — an operator using the
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
regardless of role. The access pattern is read-mostly / effectively
single-writer, which is what makes a shared SQLite volume viable as an interim
posture (see caveat below).
## How it's delivered (do NOT commit these values)
The File-KEK + shared-store posture is supplied per-node at deployment time —
**never** by editing the committed `appsettings.json` or the role-overlay files
(`appsettings.admin.json`, `appsettings.driver.json`, `appsettings.admin-driver.json`).
Those committed overlays are also consumed by local dev and the
`TwoNodeClusterHarness` integration tests, so hardcoding a `Source=File` path
into them would break every dev/test/CI boot. Two acceptable delivery
mechanisms instead:
**Option A — environment variable overrides** (Windows Service / NSSM env block,
container `env_file`, etc.), applied identically on every node regardless of role:
```
# production deployment — do not commit to the dev appsettings
Secrets__MasterKey__Source=File
Secrets__MasterKey__FilePath=/run/secrets/otopcua-master.key
Secrets__SqlitePath=/shared/secrets/otopcua-secrets.db
```
**Option B — a production-only config layer** that is *not* the committed dev
base (e.g. an untracked `appsettings.Production.json` deployed alongside the
binaries, or an orchestrator-injected config mount):
```jsonc
// production deployment — do not commit to the dev appsettings
{
"Secrets": {
"MasterKey": {
"Source": "File",
"FilePath": "/run/secrets/otopcua-master.key"
},
"SqlitePath": "/shared/secrets/otopcua-secrets.db"
}
}
```
Either way, the file/path referenced must exist and be identical on every node
**before** that node boots — the pre-host expander runs unconditionally on every
role and will throw (`SecretNotFoundException` / migration failure) if the store
or key is missing.
## Caveat: SQLite over a shared volume is not real replication
SQLite's file-locking model does not tolerate concurrent multi-writer access well
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
block volume only one writer should be active at a time). The interim posture
above is acceptable because:
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
- Writes are rare, human-initiated, and effectively single-writer in practice
(an operator runs the CLI/UI against one admin node at a time).
It is **not** a substitute for real replication, and it is not safe if multiple
nodes attempt concurrent writes. Do not build automation that writes secrets
from more than one node simultaneously.
## Data Protection is independent — do not touch it here
OtOpcUa's cookie/session protection already has its own clustered-key story:
`AddDataProtection().PersistKeysToDbContext<OtOpcUaConfigDbContext>()`
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`),
which shares the Data Protection key ring across every node via the existing
ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryption
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
work — doing so risks invalidating active sessions for an unrelated reason.
It is, however, the model for where the *next* iteration of secret storage
should go — see the G-7 hand-off below.
## The G-7 hand-off
The posture above is an interim, ops-only workaround. The long-term shape,
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
the Data Protection key ring:
```csharp
services.AddDataProtection()
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
.SetApplicationName("OtOpcUa");
```
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
the secret store is the natural next tenant of that same shared database instead
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
built as part of this cut. This runbook's shared-SQLite-volume posture is the
bridge until G-7 lands.
## Dev/test/default posture (unchanged)
The committed default in `appsettings.json` is:
```json
"Secrets": {
"SqlitePath": "otopcua-secrets.db",
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
"RunMigrationsOnStartup": true
}
```
This is dev-safe: `Source=Environment` needs no filesystem key, and the SQLite
path is relative to the working directory, so local dev, the role-overlay
appsettings (`appsettings.admin.json`, `appsettings.driver.json`,
`appsettings.admin-driver.json`), and the `TwoNodeClusterHarness` integration
tests all boot cleanly with no external mount. The File-KEK + shared-volume
posture in this runbook applies only to real clustered production deployments —
it must never be baked into the committed dev/role-overlay base, because the
expander runs unconditionally at every node boot (any role) and would break
dev/CI if pointed at a nonexistent `/shared` mount.
+49
View File
@@ -380,6 +380,55 @@ polling the node.
--- ---
## Config secrets (`${secret:}` delivery)
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
supplied at deploy time — either as a plain environment variable, or as a
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
the encrypted SQLite store lives at `Secrets:SqlitePath`.
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
The five config secrets and their canonical secret names:
| Config key | Owner | Secret name |
|---|---|---|
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
**Delivery.** By default these are delivered as plain environment variables (e.g.
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
the token in place of the literal — via env var or a deployment appsettings overlay:
```
secret set otopcua/historian/api-key <value> # seed the encrypted store first
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
```
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
secret value.
For the production posture needed so every clustered node (admin, driver, and any
fused role) resolves the same secrets from the same store, see
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
---
## Troubleshooting ## Troubleshooting
### Certificate trust failure ### Certificate trust failure
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client; using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
}; };
private readonly ILogger<GalaxyDriverBrowser> _logger; private readonly ILogger<GalaxyDriverBrowser> _logger;
private readonly ISecretResolver _secretResolver;
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary> /// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param> /// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null) public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
{ {
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance; _logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
} }
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName)) if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName."); throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
var clientOpts = BuildClientOptions(opts.Gateway); var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
// 30s wall-clock budget for the connect phase, linked to the caller's token so // 30s wall-clock budget for the connect phase, linked to the caller's token so
// an AdminUI cancel still wins early. // an AdminUI cancel still wins early.
@@ -116,13 +120,21 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
/// Build the gateway client options from the form's Gateway section. Mirrors the /// Build the gateway client options from the form's Gateway section. Mirrors the
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the /// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
/// gateway sees an identical option shape. The API-key reference is resolved via /// gateway sees an identical option shape. The API-key reference is resolved via
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts /// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step. /// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
/// object initializer (you can't await inside one).
/// </summary> /// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{ {
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger), ApiKey = apiKey,
UseTls = gw.UseTls, UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath, CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
@@ -132,3 +144,4 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
: null, : null,
}; };
} }
}
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
/// <summary> /// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is /// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms /// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
/// supported, in priority order: /// supported, in priority order:
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for /// <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> /// 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>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>
/// encrypted store; fail-closed if absent (the production path).</item>
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests; /// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
/// no startup warning.</item> /// no startup warning.</item>
/// <item>Anything else — treated as a literal cleartext API key for back-compat. /// <item>Anything else — treated as a literal cleartext API key for back-compat.
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary> /// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four /// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
/// forms supported, evaluated in order: /// forms supported, evaluated in order:
/// <list type="number"> /// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>. /// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix /// <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 /// 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> /// 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 /// <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 /// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally /// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it.</item> /// committed a cleartext key sees it.</item>
/// </list> /// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without /// A future PR can swap any of these arms for a different backing store without
/// changing the call site. /// changing the call site.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
public static class GalaxySecretRef public static class GalaxySecretRef
{ {
/// <summary> /// <summary>
/// Resolves the supplied secret reference. When the ref falls through to the /// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
/// back-compat literal arm (an unprefixed cleartext API key in /// through <paramref name="resolver"/> and is fail-closed (throws when the secret
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits /// is absent). When the ref falls through to the back-compat literal arm (an
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit /// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
/// opt-in path that doesn't warn. /// <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.
/// </summary> /// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param> /// <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="logger">Optional logger for warning on cleartext keys.</param>
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
/// <returns>The resolved API-key string.</returns> /// <returns>The resolved API-key string.</returns>
public static string ResolveApiKey(string secretRef, ILogger? logger = null) public static async Task<string> ResolveApiKeyAsync(
string secretRef,
ISecretResolver resolver,
ILogger? logger = null,
CancellationToken ct = default)
{ {
ArgumentException.ThrowIfNullOrEmpty(secretRef); ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase)) if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{ {
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
return secretRef[4..]; return secretRef[4..];
} }
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
// Production path: resolve the name through the shared encrypted secret store.
// Fail-closed — an absent/tombstoned secret throws rather than falling through
// to the literal arm (which would silently treat the ref string as the key).
var name = secretRef["secret:".Length..];
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
}
// Back-compat literal arm. An unprefixed string is treated as the literal // 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 // 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 // cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
@@ -13,5 +13,6 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" /> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. --> <!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
private readonly GalaxyDriverOptions _options; private readonly GalaxyDriverOptions _options;
private readonly ILogger<GalaxyDriver> _logger; private readonly ILogger<GalaxyDriver> _logger;
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
private readonly ISecretResolver _secretResolver;
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver // PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on // lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise // first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param> /// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="options">The Galaxy driver configuration options.</param> /// <param name="options">The Galaxy driver configuration options.</param>
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
/// <param name="logger">Optional logger instance for diagnostics.</param> /// <param name="logger">Optional logger instance for diagnostics.</param>
public GalaxyDriver( public GalaxyDriver(
string driverInstanceId, string driverInstanceId,
GalaxyDriverOptions options, GalaxyDriverOptions options,
ISecretResolver secretResolver,
ILogger<GalaxyDriver>? logger = null) ILogger<GalaxyDriver>? logger = null)
: this(driverInstanceId, options, : this(driverInstanceId, options,
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null, hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
alarmAcknowledger: null, alarmFeed: null, logger) alarmAcknowledger: null, alarmFeed: null, logger,
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
{ {
} }
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param> /// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param> /// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
/// <param name="logger">Optional logger instance for diagnostics.</param> /// <param name="logger">Optional logger instance for diagnostics.</param>
/// <param name="secretResolver">
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
/// null-object resolver (returns null for every name) so seam-injecting tests that
/// don't exercise a <c>secret:</c> ref need not supply one.
/// </param>
internal GalaxyDriver( internal GalaxyDriver(
string driverInstanceId, string driverInstanceId,
GalaxyDriverOptions options, GalaxyDriverOptions options,
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
IGalaxySubscriber? subscriber = null, IGalaxySubscriber? subscriber = null,
IGalaxyAlarmAcknowledger? alarmAcknowledger = null, IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
IGalaxyAlarmFeed? alarmFeed = null, IGalaxyAlarmFeed? alarmFeed = null,
ILogger<GalaxyDriver>? logger = null) ILogger<GalaxyDriver>? logger = null,
ISecretResolver? secretResolver = null)
{ {
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
? driverInstanceId ? driverInstanceId
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId)); : throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance; _logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_hierarchySource = hierarchySource; _hierarchySource = hierarchySource;
_dataReader = dataReader; _dataReader = dataReader;
_dataWriter = dataWriter; _dataWriter = dataWriter;
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
_driverInstanceId); _driverInstanceId);
} }
StartDeployWatcher(); await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation( _logger.LogInformation(
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}", "GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName); _driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
/// </summary> /// </summary>
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken) private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
{ {
var clientOptions = BuildClientOptions(_options.Gateway); var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
_ownedMxClient = MxGatewayClient.Create(clientOptions); _ownedMxClient = MxGatewayClient.Create(clientOptions);
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger); _ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false); await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
private async Task ReopenAsync(CancellationToken cancellationToken) private async Task ReopenAsync(CancellationToken cancellationToken)
{ {
if (_ownedMxSession is null) return; if (_ownedMxSession is null) return;
var clientOptions = BuildClientOptions(_options.Gateway); var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false); await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise // The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session. // caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
} }
} }
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
// async and you can't await inside an initializer. Pass the logger so the
// literal-arm cleartext fallback surfaces a startup warning rather than
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
// implementation; the secret: arm resolves through the shared ISecretResolver.
var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{ {
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
// Pass the logger so the literal-arm cleartext fallback surfaces a startup ApiKey = apiKey,
// warning rather than silently shipping the key. The resolver lives in
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
// AdminUI browser share one implementation.
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls, UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath, CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null, StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
}; };
}
private void StartDeployWatcher() private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
{ {
if (!_options.Repository.WatchDeployEvents) return; if (!_options.Repository.WatchDeployEvents) return;
if (_ownedRepositoryClient is null && _hierarchySource is null) return; if (_ownedRepositoryClient is null && _hierarchySource is null) return;
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand). // Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
// If discovery hasn't run yet, build the client here so the watcher has a target. // If discovery hasn't run yet, build the client here so the watcher has a target.
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client // Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
// rather than overwriting the field and leaking the first instance. // runs it reuses this client rather than overwriting the field and leaking the
// first instance — the client-options build is now async (secret: arm).
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create( _ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
BuildClientOptions(_options.Gateway)); await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient); var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
_deployWatcher = new DeployWatcher(source, _logger); _deployWatcher = new DeployWatcher(source, _logger);
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so // After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute. // newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
var source = _hierarchySource ??= BuildDefaultHierarchySource(); var source = _hierarchySource ??=
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as // Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then // its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
// keyed by RawPath, matching what WriteAsync resolves against. // keyed by RawPath, matching what WriteAsync resolves against.
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the /// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
/// internal ctor. /// internal ctor.
/// </summary> /// </summary>
private IGalaxyHierarchySource BuildDefaultHierarchySource() private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
{ {
// Reuse a client that StartDeployWatcher may have already created (??=) rather // Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
// than always overwriting the field and leaking the first instance. Both paths // than always overwriting the field and leaking the first instance. Both paths
// produce equivalent clients from the same options. // produce equivalent clients from the same options. The client-options build is
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway)); // async now (secret: arm resolves through the shared ISecretResolver).
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
return new TracedGalaxyHierarchySource( return new TracedGalaxyHierarchySource(
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName); new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
} }
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
{ {
public const string DriverTypeName = "GalaxyMxGateway"; public const string DriverTypeName = "GalaxyMxGateway";
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary> /// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
/// <param name="registry">The driver factory registry to register with.</param> /// <param name="registry">The driver factory registry to register with.</param>
/// <param name="secretResolver">
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
/// </param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param> /// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) public static void Register(
DriverFactoryRegistry registry,
ISecretResolver secretResolver,
ILoggerFactory? loggerFactory = null)
{ {
ArgumentNullException.ThrowIfNull(registry); ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); ArgumentNullException.ThrowIfNull(secretResolver);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
} }
/// <summary>Convenience for tests + standalone callers.</summary> /// <summary>
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
/// threads the DI resolver.
/// </summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param> /// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param> /// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <returns>The constructed Galaxy driver instance.</returns> /// <returns>The constructed Galaxy driver instance.</returns>
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson) public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary> /// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param> /// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param> /// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param> /// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
/// <returns>The constructed Galaxy driver instance.</returns> /// <returns>The constructed Galaxy driver instance.</returns>
public static GalaxyDriver CreateInstance( public static GalaxyDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
{ {
ArgumentNullException.ThrowIfNull(secretResolver);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
RawTags = dto.RawTags ?? [], RawTags = dto.RawTags ?? [],
}; };
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>()); return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
} }
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@@ -0,0 +1,26 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// <summary>
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
/// (absent). Used as the default for the internal test ctor and the parse-only
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
/// null object throws fail-closed (the secret is reported absent), which is the correct
/// behaviour for a mis-wired deployment.
/// </summary>
internal sealed class NullSecretResolver : ISecretResolver
{
/// <summary>The shared singleton instance.</summary>
public static readonly NullSecretResolver Instance = new();
private NullSecretResolver()
{
}
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(null);
}
@@ -14,7 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal /// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
/// protections cover it. /// protections cover it.
/// </remarks> /// </remarks>
public sealed class OpcUaClientDriverOptions // A record (not a plain class) so G-2b's OpcUaClientSecretResolution can produce a
// credential-resolved copy with a `with` expression — every property keeps its init setter.
public sealed record OpcUaClientDriverOptions
{ {
/// <summary> /// <summary>
/// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience /// Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>. Convenience
@@ -0,0 +1,26 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
/// (absent). Backs the driver's default (test/parse-only) construction path so callers that
/// never author a <c>secret:</c> credential ref need not thread a real resolver. A production
/// <c>OpcUaClientDriver</c> always receives the DI-registered resolver via the factory; a
/// <c>secret:</c> ref resolved against this null object throws fail-closed (the secret is
/// reported absent), which is the correct behaviour for a mis-wired deployment — the
/// <c>secret:</c> literal is never sent verbatim as a password.
/// </summary>
internal sealed class NullSecretResolver : ISecretResolver
{
/// <summary>The shared singleton instance.</summary>
public static readonly NullSecretResolver Instance = new();
private NullSecretResolver()
{
}
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(null);
}
@@ -4,6 +4,7 @@ using Opc.Ua;
using Opc.Ua.Client; using Opc.Ua.Client;
using Opc.Ua.Configuration; using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -36,15 +37,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <param name="options">Driver configuration.</param> /// <param name="options">Driver configuration.</param>
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param> /// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param> /// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
/// <param name="secretResolver">
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> /
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
/// secret absent) for the test/parse-only path; the production factory threads the
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
/// </param>
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId, public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
ILogger<OpcUaClientDriver>? logger = null) ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
{ {
_options = options; _options = options;
_driverInstanceId = driverInstanceId; _driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance; _logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
} }
private readonly OpcUaClientDriverOptions _options; private readonly OpcUaClientDriverOptions _options;
private readonly ISecretResolver _secretResolver;
private readonly string _driverInstanceId; private readonly string _driverInstanceId;
// ---- IAlarmSource state ---- // ---- IAlarmSource state ----
@@ -159,7 +170,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false); var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
var candidates = ResolveEndpointCandidates(_options); var candidates = ResolveEndpointCandidates(_options);
var identity = BuildUserIdentity(_options); // G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
// shared secret store ONCE, here in the async connect flow, just before the credentials
// are consumed. _options stays the raw config (with secret: refs) — only this
// connect-scoped local carries plaintext, and only for the duration of the connect.
// Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations.
// Both credential consumers — the Username password below and the Certificate password
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
// single resolve covers both paths.
var resolvedOptions = await OpcUaClientSecretResolution
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
var identity = BuildUserIdentity(resolvedOptions);
// Failover sweep: try each endpoint in order, return the session from the first // Failover sweep: try each endpoint in order, return the session from the first
// one that successfully connects. Per-endpoint failures are captured so the final // one that successfully connects. Per-endpoint failures are captured so the final
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -31,22 +32,37 @@ public static class OpcUaClientDriverFactoryExtensions
Converters = { new JsonStringEnumConverter() }, Converters = { new JsonStringEnumConverter() },
}; };
/// <summary>Register the OpcUaClient factory with the driver registry.</summary> /// <summary>Register the OpcUaClient factory with the driver registry, threading the shared secret resolver.</summary>
/// <param name="registry">The driver factory registry to register with.</param> /// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param> /// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) /// <param name="secretResolver">
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of each
/// instance's <c>Password</c> / <c>UserCertificatePassword</c> at session-open. Defaults to
/// the <see cref="NullSecretResolver"/> null-object for the test/standalone path — callers
/// that need a working <c>secret:</c> credential ref must thread the DI resolver.
/// </param>
public static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory = null,
ISecretResolver? secretResolver = null)
{ {
ArgumentNullException.ThrowIfNull(registry); ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); var resolver = secretResolver ?? NullSecretResolver.Instance;
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, resolver));
} }
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary> /// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param> /// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param> /// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param> /// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
/// <param name="secretResolver">
/// Optional shared secret resolver for the driver's <c>secret:</c> credential arm; defaults
/// to the <see cref="NullSecretResolver"/> null-object (fail-closed on a <c>secret:</c> ref).
/// </param>
/// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns> /// <returns>A configured <see cref="OpcUaClientDriver"/>.</returns>
public static OpcUaClientDriver CreateInstance( public static OpcUaClientDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null) string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
ISecretResolver? secretResolver = null)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -55,6 +71,8 @@ public static class OpcUaClientDriverFactoryExtensions
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null"); $"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
return new OpcUaClientDriver(options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>()); return new OpcUaClientDriver(
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
secretResolver ?? NullSecretResolver.Instance);
} }
} }
@@ -34,6 +34,12 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
/// <inheritdoc /> /// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{ {
// G-2b note: the probe does an UNAUTHENTICATED GetEndpoints discovery preflight
// (BuildMinimalAppConfig sets no user identity) and never reads opts.Password /
// opts.UserCertificatePassword. Those credentials — including any secret: refs — are
// therefore intentionally NOT resolved here; resolving them would be dead code. Secret
// resolution happens lazily in OpcUaClientDriver's async session-open path, where the
// credentials are actually consumed.
OpcUaClientDriverOptions? opts; OpcUaClientDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); } try { opts = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
@@ -0,0 +1,90 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Layer-B (G-2b) secret resolution for the OPC UA Client driver's credential fields. Resolves
/// <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/> and
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
/// <see cref="ISecretResolver"/> (the encrypted-at-rest store) just before the async
/// session-open path consumes them — retiring the cleartext password-in-DB model for
/// production.
/// </summary>
/// <remarks>
/// Resolution is lazy (called from the connect flow, not at config deserialization) so a
/// full reconnect via <c>InitializeAsync</c> re-resolves and picks up secret rotations,
/// mirroring the Galaxy driver's re-resolve-on-reconnect behaviour. Only <c>secret:</c>-prefixed
/// values are resolved; a null/empty or non-<c>secret:</c> value passes through verbatim so the
/// literal-password back-compat path is preserved. The <c>secret:</c> arm is <b>fail-closed</b>:
/// an absent/tombstoned secret throws rather than leaving the <c>secret:</c> literal in place,
/// which would otherwise be sent verbatim to the remote server as the password.
/// </remarks>
internal static class OpcUaClientSecretResolution
{
private const string SecretPrefix = "secret:";
/// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> /
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved to their
/// plaintext via <paramref name="resolver"/>. Non-secret / null / empty fields are
/// returned unchanged.
/// </summary>
/// <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>
/// <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 async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
.ConfigureAwait(false);
// Only re-materialize when something actually changed — a `with` on the reference-equal
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
if (ReferenceEquals(password, options.Password)
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
{
return options;
}
return options with { Password = password, UserCertificatePassword = certPassword };
}
/// <summary>
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
/// </summary>
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
/// <param name="resolver">The shared secret resolver.</param>
/// <param name="ct">Cancellation token for the async resolution.</param>
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
{
return value;
}
var name = value[SecretPrefix.Length..];
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).");
}
}
@@ -21,6 +21,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -19,7 +19,7 @@
<HeadOutlet/> <HeadOutlet/>
</head> </head>
<body> <body>
<Routes/> <Routes AdditionalAssemblies="@(new[] { typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly })" />
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<ThemeScripts /> <ThemeScripts />
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script> <script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
@@ -20,6 +20,7 @@
<NavRailItem Href="/reservations" Text="Reservations" /> <NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" /> <NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" /> <NavRailItem Href="/role-grants" Text="Role grants" />
<NavRailItem Href="/admin/secrets" Text="Secrets" />
</NavRailSection> </NavRailSection>
<NavRailSection Title="Scripting" Key="scripting"> <NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/scripts" Text="Scripts" /> <NavRailItem Href="/scripts" Text="Scripts" />
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
@@ -10,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI; namespace ZB.MOM.WW.OtOpcUa.AdminUI;
@@ -28,8 +30,13 @@ public static class EndpointRouteBuilderExtensions
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are // Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE // served via the Host's app.UseStaticFiles() middleware which must run BEFORE
// UseAuthentication() — see Program.cs. // UseAuthentication() — see Program.cs.
// The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its
// routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) —
// the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR
// endpoint discovery. Both registrations are required or /admin/secrets 404s.
app.MapRazorComponents<TApp>() app.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly);
return app; return app;
} }
@@ -43,6 +50,15 @@ public static class EndpointRouteBuilderExtensions
services.AddRazorComponents().AddInteractiveServerComponents(); services.AddRazorComponents().AddInteractiveServerComponents();
services.AddOtOpcUaDriverStatusServices(); services.AddOtOpcUaDriverStatusServices();
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
// composition layer that already references the RCL) rather than in the core Security lib.
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md // Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>(); services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>(); services.AddHostedService<Browsing.BrowseSessionReaper>();
@@ -10,6 +10,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App"/> <FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
<PackageReference Include="ZB.MOM.WW.Theme"/> <PackageReference Include="ZB.MOM.WW.Theme"/>
<PackageReference Include="ZB.MOM.WW.Secrets.Ui"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience; using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers; namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
// The calc driver needs the root script logger so a script failure fans out onto the // The calc driver needs the root script logger so a script failure fans out onto the
// script-logs topic; resolve it here (null on nodes without the script pipeline wired). // script-logs topic; resolve it here (null on nodes without the script pipeline wired).
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>(); var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
Register(registry, loggerFactory, scriptRoot); // The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
var secretResolver = sp.GetRequiredService<ISecretResolver>();
Register(registry, loggerFactory, secretResolver, scriptRoot);
return registry; return registry;
}); });
services.AddSingleton<IDriverFactory>(sp => services.AddSingleton<IDriverFactory>(sp =>
@@ -131,15 +135,16 @@ public static class DriverFactoryBootstrap
private static void Register( private static void Register(
DriverFactoryRegistry registry, DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory, ILoggerFactory? loggerFactory,
ISecretResolver secretResolver,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null) ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{ {
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry); Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot); Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
} }
@@ -37,6 +37,11 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.Telemetry.Serilog; using ZB.MOM.WW.Telemetry.Serilog;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api; using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser. // Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")); var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
@@ -72,6 +77,27 @@ if (roleSuffix is not null)
builder.Configuration.AddCommandLine(args); builder.Configuration.AddCommandLine(args);
} }
// Pre-host ${secret:} expansion: rewrites ${secret:name} tokens in the assembled configuration
// BEFORE the host is built, so every downstream binder/validator sees resolved plaintext.
// Placement matters: this runs AFTER all config providers are assembled (base appsettings, the
// role overlay, and the env-vars + command-line re-append above) but BEFORE anything READS a
// config value (AddZbSerilog's ReadFrom.Configuration, AddOtOpcUaConfigDb, and the first
// ValidateOnStart bind all follow). With no ${secret:} tokens present this is a no-op pass;
// it also migrates the secrets SQLite store on startup.
// ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it
// cannot use the app's DI. Fully disposed here; shares no singletons with the host container.
#pragma warning disable ASP0000
await using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(builder.Configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default);
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default);
}
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every // Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows // relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink // service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
@@ -323,6 +349,9 @@ if (hasAdmin)
builder.Services.AddOtOpcUaAdminClients(); builder.Services.AddOtOpcUaAdminClients();
} }
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddOtOpcUaHealth(); builder.Services.AddOtOpcUaHealth();
builder.Services.AddOtOpcUaObservability(builder.Configuration); builder.Services.AddOtOpcUaObservability(builder.Configuration);
@@ -33,6 +33,8 @@
<PackageReference Include="ZB.MOM.WW.Telemetry" /> <PackageReference Include="ZB.MOM.WW.Telemetry" />
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" /> <PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
<PackageReference Include="ZB.MOM.WW.Configuration" /> <PackageReference Include="ZB.MOM.WW.Configuration" />
<PackageReference Include="ZB.MOM.WW.Secrets" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -11,12 +11,21 @@
"DisableLogin": false "DisableLogin": false
} }
}, },
"Secrets": {
"SqlitePath": "otopcua-secrets.db",
"MasterKey": {
"Source": "Environment",
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
},
"RunMigrationsOnStartup": true,
"ResolveCacheTtl": "00:00:30"
},
"ServerHistorian": { "ServerHistorian": {
"_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.", "_comment": "Server-side HistoryRead backend (the ZB.MOM.WW.HistorianGateway gRPC client). Disabled => NullHistorianDataSource (historized nodes return GoodNoData). The gateway must run RuntimeDb:EventReadsEnabled=true for alarm-history ReadEvents, and the API key must carry historian:read + historian:write + historian:tags:write scopes.",
"Enabled": false, "Enabled": false,
"Endpoint": "", "Endpoint": "",
"ApiKey": "", "ApiKey": "",
"_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.", "_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.",
"UseTls": true, "UseTls": true,
"AllowUntrustedServerCertificate": false, "AllowUntrustedServerCertificate": false,
"CaCertificatePath": null, "CaCertificatePath": null,
@@ -799,6 +799,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.) // (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null; if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
// #473 — the mandatory BaseEventType identity fields. Create() builds these three children from
// the type's embedded definition but leaves them UNSET (the init string declares them mandatory
// with NO default), and nothing downstream fills them in: the auto-filling
// BaseEventState.Initialize(context, source, severity, message) overload is only used for
// transient events, and ReportEvent / InstanceStateSnapshot copy the children verbatim. Unset
// here ⇒ null on the wire on EVERY condition event, so a client cannot attribute the alarm.
// SourceNode — the condition's OWN NodeId (== ConditionId): the condition IS the source. An
// alarm-bearing raw tag materialises ONLY this condition, with no sibling value
// variable (see AddressSpaceApplier's alarm branch), so there is no other node.
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
// is already carried by ConditionName below. See docs/AlarmTracking.md.
alarm.EventType.Value = alarm.TypeDefinitionId;
alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
alarm.SourceName.Value = alarmNodeId;
// Initial state via the SDK setters (T14: basic state only, NO event firing). // Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true); alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false); alarm.SetActiveState(SystemContext, false);
@@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions
/// <summary> /// <summary>
/// The peppered-HMAC API key (<c>histgw_&lt;id&gt;_&lt;secret&gt;</c>) the gateway validates /// The peppered-HMAC API key (<c>histgw_&lt;id&gt;_&lt;secret&gt;</c>) the gateway validates
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable /// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when /// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
/// <see cref="Enabled"/> is <c>true</c>. /// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
/// expander resolves fail-closed (throwing if the secret is absent) before this options
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
/// </summary> /// </summary>
public string ApiKey { get; init; } = ""; public string ApiKey { get; init; } = "";
@@ -1,5 +1,6 @@
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
@@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class GalaxyDriverBrowserTests public sealed class GalaxyDriverBrowserTests
{ {
private readonly GalaxyDriverBrowser _sut = new(); // These unit tests exercise only the pre-connect validation paths (empty endpoint,
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
// null-returning stub resolver suffices — it is never consulted.
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value /// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
/// so the factory wire-up picks the right browser implementation.</summary> /// so the factory wire-up picks the right browser implementation.</summary>
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary> /// <summary>
/// Follow-up #2 — pins the three resolution forms supported by /// Follow-up #2 + G-2a — pins the five resolution forms supported by
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>, /// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without /// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to /// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime /// throws rather than leaking the ref string as the key. (The resolver was extracted from
/// driver and the AdminUI browser share one copy.) /// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
/// runtime driver and the AdminUI browser share one copy.)
/// </summary> /// </summary>
public sealed class GalaxyDriverApiKeyResolverTests public sealed class GalaxyDriverApiKeyResolverTests
{ {
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
private const string KnownSecretName = "galaxy/inst1/apikey";
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
private const string KnownSecretValue = "key-from-secret-store";
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
private static ISecretResolver FakeResolver() => new StubSecretResolver();
/// <summary>Verifies that a literal string is returned unchanged.</summary> /// <summary>Verifies that a literal string is returned unchanged.</summary>
[Fact] [Fact]
public void Literal_string_is_returned_unchanged() public async Task Literal_string_is_returned_unchanged()
{ {
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key"); (await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver()))
.ShouldBe("plain-text-key");
} }
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary> /// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
[Fact] [Fact]
public void Env_prefix_resolves_to_environment_variable() public async Task Env_prefix_resolves_to_environment_variable()
{ {
const string name = "OTOPCUA_TEST_GALAXY_API_KEY"; const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
Environment.SetEnvironmentVariable(name, "key-from-env"); Environment.SetEnvironmentVariable(name, "key-from-env");
try try
{ {
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env"); (await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
.ShouldBe("key-from-env");
} }
finally finally
{ {
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary> /// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
[Fact] [Fact]
public void Env_prefix_unset_variable_throws_with_descriptive_message() public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
{ {
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET"; const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
Environment.SetEnvironmentVariable(name, null); Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() => var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"env:{name}")); GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
ex.Message.ShouldContain(name); ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset"); ex.Message.ShouldContain("unset");
} }
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary> /// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
[Fact] [Fact]
public void File_prefix_resolves_to_trimmed_file_contents() public async Task File_prefix_resolves_to_trimmed_file_contents()
{ {
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt"); var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n"); File.WriteAllText(path, " key-from-file \n");
try try
{ {
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file"); (await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
.ShouldBe("key-from-file");
} }
finally finally
{ {
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that file: prefix with missing path throws.</summary> /// <summary>Verifies that file: prefix with missing path throws.</summary>
[Fact] [Fact]
public void File_prefix_missing_path_throws() public async Task File_prefix_missing_path_throws()
{ {
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt"); var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() => var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"file:{path}")); GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain(path); ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist"); ex.Message.ShouldContain("doesn't exist");
} }
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
[Fact]
public async Task Secret_prefix_resolves_through_secret_resolver()
{
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver()))
.ShouldBe(KnownSecretValue);
}
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
[Fact]
public async Task Secret_prefix_absent_secret_throws_fail_closed()
{
const string missingRef = "secret:galaxy/missing/apikey";
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
// must NOT return the ref string as the key.
ex.Message.ShouldContain("galaxy/missing/apikey");
ex.Message.ShouldContain("absent");
}
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
[Fact]
public async Task Secret_prefix_does_not_emit_literal_warning()
{
var logger = new CaptureLogger();
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path ===== // ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary> /// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
[Fact] [Fact]
public void Literal_string_emits_warning_when_logger_supplied() public async Task Literal_string_emits_warning_when_logger_supplied()
{ {
// A literal API key on a production deployment means the cleartext key sits // A literal API key on a production deployment means the cleartext key sits
// in the DriverConfig JSON. The resolver must surface a warning so an // in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup. // operator who committed one by accident sees it at startup.
var logger = new CaptureLogger(); var logger = new CaptureLogger();
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger); var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger);
key.ShouldBe("plain-text-key"); key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e => logger.Entries.ShouldContain(e =>
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary> /// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
[Fact] [Fact]
public void Dev_prefix_returns_literal_without_warning() public async Task Dev_prefix_returns_literal_without_warning()
{ {
// An explicit dev: prefix signals the operator knowingly opted into a literal // An explicit dev: prefix signals the operator knowingly opted into a literal
// key (dev / parity rig). The resolver must accept it AND suppress the // key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice. // warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger(); var logger = new CaptureLogger();
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger); var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger);
key.ShouldBe("plain-text-key"); key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
@@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary> /// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
[Fact] [Fact]
public void Env_prefix_does_not_emit_literal_warning() public async Task Env_prefix_does_not_emit_literal_warning()
{ {
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN"; const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
Environment.SetEnvironmentVariable(name, "v"); Environment.SetEnvironmentVariable(name, "v");
try try
{ {
var logger = new CaptureLogger(); var logger = new CaptureLogger();
GalaxySecretRef.ResolveApiKey($"env:{name}", logger); await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning); logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
} }
finally finally
@@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
} }
} }
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
[Fact]
public async Task Null_resolver_throws()
{
await Should.ThrowAsync<ArgumentNullException>(() =>
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
}
/// <summary>A test logger that captures log entries for verification.</summary> /// <summary>A test logger that captures log entries for verification.</summary>
private sealed class CaptureLogger : ILogger private sealed class CaptureLogger : ILogger
{ {
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
=> Entries.Add((logLevel, formatter(state, exception))); => Entries.Add((logLevel, formatter(state, exception)));
} }
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
? KnownSecretValue
: null);
}
/// <summary>Verifies that file: prefix with empty file throws.</summary> /// <summary>Verifies that file: prefix with empty file throws.</summary>
[Fact] [Fact]
public void File_prefix_empty_file_throws() public async Task File_prefix_empty_file_throws()
{ {
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt"); var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " \n "); File.WriteAllText(path, " \n ");
try try
{ {
var ex = Should.Throw<InvalidOperationException>(() => var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKey($"file:{path}")); GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain("empty"); ex.Message.ShouldContain("empty");
} }
finally finally
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
public void Register_AddsFactoryToRegistry() public void Register_AddsFactoryToRegistry()
{ {
var registry = new DriverFactoryRegistry(); var registry = new DriverFactoryRegistry();
GalaxyDriverFactoryExtensions.Register(registry); GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName); registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
@@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests
// _dataReader and _subscriber are both null. The follow-up read path can't // _dataReader and _subscriber are both null. The follow-up read path can't
// synthesise a Read without one, so it surfaces a NotSupportedException // synthesise a Read without one, so it surfaces a NotSupportedException
// pointing at the misuse rather than NullRef'ing inside the pump path. // pointing at the misuse rather than NullRef'ing inside the pump path.
var driver = new GalaxyDriver("g", Opts()); var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
var ex = await Should.ThrowAsync<NotSupportedException>(() => var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None)); driver.ReadAsync(["x"], CancellationToken.None));
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
[Fact] [Fact]
public async Task SubscribeAsync_NoSubscriber_Throws() public async Task SubscribeAsync_NoSubscriber_Throws()
{ {
using var driver = new GalaxyDriver("g", Opts()); using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
var ex = await Should.ThrowAsync<NotSupportedException>(() => var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None)); driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires"); ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
[Fact] [Fact]
public async Task WriteAsync_NoWriter_Throws() public async Task WriteAsync_NoWriter_Throws()
{ {
var driver = new GalaxyDriver("g", Opts()); var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
var ex = await Should.ThrowAsync<NotSupportedException>(() => var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None)); driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
/// Non-secret literals and null/empty pass through unchanged.
/// </summary>
public sealed class OpcUaClientSecretResolutionTests
{
private const string KnownPwName = "opcua/inst/password";
private const string KnownPwValue = "resolved-password-plaintext";
private const string KnownCertPwName = "opcua/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>
private static ISecretResolver FakeResolver() => new StubSecretResolver();
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
[Fact]
public async Task Secret_password_resolves_to_store_plaintext()
{
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None);
resolved.Password.ShouldBe(KnownPwValue);
}
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
[Fact]
public async Task Secret_cert_password_resolves_to_store_plaintext()
{
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None);
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
}
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
[Fact]
public async Task Literal_and_null_pass_through_unchanged()
{
var options = new OpcUaClientDriverOptions
{
Password = "plainpw",
UserCertificatePassword = null,
};
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None);
resolved.Password.ShouldBe("plainpw");
resolved.UserCertificatePassword.ShouldBeNull();
}
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
/// and never leaves the <c>secret:</c> literal in place.</summary>
[Fact]
public async Task Unknown_secret_ref_is_fail_closed()
{
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None));
ex.Message.ShouldContain("Password");
ex.Message.ShouldContain("opcua/inst/absent");
}
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
Task.FromResult<string?>(name.Value switch
{
KnownPwName => KnownPwValue,
KnownCertPwName => KnownCertPwValue,
_ => null,
});
}
}
@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Secrets-adoption (G-2c) regression guard: the AdminUI driver-config editor persists <c>DriverConfig</c>
/// as an OPAQUE JSON string through <see cref="RawTreeService"/> (see <c>UpdateDriverAsync</c> /
/// <c>CreateDriverAsync</c> — <c>entity.DriverConfig = driverConfigJson</c> verbatim, and
/// <c>LoadDriverForEditAsync</c> reads it back verbatim). A <c>secret:</c>/<c>env:</c>/<c>file:</c>
/// reference stored in a driver secret field (OpcUaClient <c>Password</c>/<c>UserCertificatePassword</c>,
/// Galaxy <c>Gateway.ApiKeySecretRef</c>) MUST survive save→reload as the literal ref string — the save
/// path must never resolve-then-resave cleartext (which would re-leak the secret into the config store and
/// defeat G-2). Secret resolution belongs ONLY at driver-instantiation / Test-connect time (Tasks 7-8),
/// never on the AdminUI persistence path — and indeed <see cref="RawTreeService"/> takes no secret resolver
/// (its only dependency is the DbContext factory), so resolution is structurally impossible here.
/// <para>
/// Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture, matching the WP3 driver-config tests.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTreeServiceSecretRefRoundTripTests
{
private static (RawTreeService Service, string DbName) Seeded()
{
var name = RawTreeTestDb.SeedFresh();
return (new RawTreeService(RawTreeTestDb.Factory(name)), name);
}
private static byte[] RowVersionOf(string dbName, string driverId)
{
using var db = RawTreeTestDb.CreateNamed(dbName);
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
}
[Fact]
public async Task UpdateDriver_preserves_OpcUaClient_secret_refs_verbatim_on_save_and_reload()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
// The editor emits the raw ref strings the user typed — they must NOT be resolved on save.
const string config =
"{\"Password\":\"secret:opcua/x/pw\",\"UserCertificatePassword\":\"secret:opcua/x/certpw\"}";
var result = await service.UpdateDriverAsync(
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
result.Ok.ShouldBeTrue();
// Reload through the same seam the modal uses.
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
reloaded.ShouldNotBeNull();
// Byte-identical: nothing on the save path rewrote the string.
reloaded!.DriverConfig.ShouldBe(config);
// The literal refs survived (not resolved to cleartext, not stripped).
reloaded.DriverConfig.ShouldContain("secret:opcua/x/pw");
reloaded.DriverConfig.ShouldContain("secret:opcua/x/certpw");
// And the raw column agrees with the projection.
using var db = RawTreeTestDb.CreateNamed(dbName);
db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId)
.DriverConfig.ShouldBe(config);
}
[Fact]
public async Task CreateDriver_preserves_Galaxy_ApiKeySecretRef_verbatim_on_save_and_reload()
{
var (service, dbName) = Seeded();
const string config = "{\"Gateway\":{\"ApiKeySecretRef\":\"secret:galaxy/x/apikey\"}}";
var created = await service.CreateDriverAsync(
RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "galaxy-1", "Galaxy", config);
created.Ok.ShouldBeTrue();
created.CreatedId.ShouldNotBeNull();
var reloaded = await service.LoadDriverForEditAsync(created.CreatedId!);
reloaded.ShouldNotBeNull();
reloaded!.DriverConfig.ShouldBe(config);
reloaded.DriverConfig.ShouldContain("secret:galaxy/x/apikey");
using var db = RawTreeTestDb.CreateNamed(dbName);
db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId)
.DriverConfig.ShouldBe(config);
}
[Fact]
public async Task UpdateDriver_preserves_env_and_file_ref_forms_verbatim()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, RawTreeTestDb.FolderedDriverId);
const string config =
"{\"Password\":\"env:OPCUA_PW\",\"UserCertificatePassword\":\"file:/run/secrets/certpw\"}";
var result = await service.UpdateDriverAsync(
RawTreeTestDb.FolderedDriverId, "modbus-1", config, null, rv);
result.Ok.ShouldBeTrue();
var reloaded = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
reloaded!.DriverConfig.ShouldBe(config);
reloaded.DriverConfig.ShouldContain("env:OPCUA_PW");
reloaded.DriverConfig.ShouldContain("file:/run/secrets/certpw");
}
}
@@ -4,6 +4,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience; using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Host.Drivers; using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -24,6 +25,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories(); services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider(); using var sp = services.BuildServiceProvider();
@@ -38,6 +42,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories(); services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider(); using var sp = services.BuildServiceProvider();
@@ -54,6 +61,9 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
// The driver-factory registration resolves ISecretResolver (Galaxy/OpcUaClient secret: refs);
// the real host always has it via AddZbSecrets (registered unconditionally). Mirror that here.
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories(); services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider(); using var sp = services.BuildServiceProvider();
@@ -65,4 +75,12 @@ public sealed class ResilienceInvokerFactoryRegistrationTests
sp.GetRequiredService<DriverResiliencePipelineBuilder>() sp.GetRequiredService<DriverResiliencePipelineBuilder>()
.ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton"); .ShouldBeSameAs(sp.GetRequiredService<DriverResiliencePipelineBuilder>(), "builder must be a singleton");
} }
// Minimal ISecretResolver so AddOtOpcUaDriverFactories' registration (which resolves it for
// Galaxy/OpcUaClient secret: refs) can be built. These tests exercise no secret: refs, so
// returning null for every name is sufficient — the real resolver comes from AddZbSecrets.
private sealed class StubSecretResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
} }
@@ -0,0 +1,257 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Issue #473 — the over-the-wire proof that a live condition event carries the mandatory
/// <c>BaseEventType</c> identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. All three
/// previously arrived <b>null</b> on every condition event: <c>MaterialiseAlarmCondition</c> never assigned
/// them, and nothing downstream synthesises them (<c>ReportEvent</c> / <c>InstanceStateSnapshot</c> copy the
/// condition's children verbatim), so a conforming client could not attribute an alarm to its source.
///
/// <para>This is the WIRE-level guard the node-level
/// <c>NodeManagerAlarmSourceFieldsTests</c> cannot give: it proves the values survive the snapshot +
/// serialization all the way into a real client's <c>EventFieldList</c>. The select clause is deliberately
/// the exact one a real consumer uses — ScadaBridge's Data Connection Layer selects
/// <c>[EventType, SourceNode, SourceName, Time, Message, Severity]</c> — so this test fails the way that
/// client failed.</para>
///
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> and drives the production
/// <see cref="SdkAddressSpaceSink"/>, mirroring <c>NativeAlarmMultiNotifierEventDeliveryTests</c>. Heavy
/// in-process server+client integration — runs in the serial integration pass.</para>
/// </summary>
public sealed class NativeAlarmEventIdentityFieldDeliveryTests
{
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
private const string RawDeviceFolder = "pymodbus/plc";
private const string RawAlarmPath = "pymodbus/plc/HR200";
private const string LeafName = "HR200";
private const string Equip1 = "EQ-filling-line1-station1";
private const string AlarmMessage = "HR200 over limit (identity-field test)";
// Field indices in the select clause built by BuildEventFilter — ScadaBridge's exact clause.
private const int EventTypeIndex = 0;
private const int SourceNodeIndex = 1;
private const int SourceNameIndex = 2;
private const int TimeIndex = 3;
private const int MessageIndex = 4;
private const int SeverityIndex = 5;
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
[Fact]
public async Task Condition_event_carries_populated_EventType_SourceNode_and_SourceName_on_the_wire()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-identity-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireCondition(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
// Resolve the RAW namespace index CLIENT-side (from the server's advertised NamespaceArray) so the
// expected SourceNode is built exactly as a real client would resolve it.
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
rawNs.ShouldBeGreaterThan((ushort)0);
var collector = new EventCollector();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
// The collector filters by our unique Message, so the item's ClientHandle is not needed here
// (unlike the sibling multi-notifier test, which tallies delivery per monitored item).
AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
var fields = collector.Fields.ShouldHaveSingleItem();
// --- the three fields this issue is about ---
// EventType: the concrete condition type, readable as a FIELD (not just via an OfType where-clause).
fields[EventTypeIndex].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType,
"EventType must carry the concrete condition type, not null");
// SourceNode: the condition's own NodeId in the RAW namespace == ConditionId.
fields[SourceNodeIndex].Value.ShouldBe(new NodeId(RawAlarmPath, rawNs),
"SourceNode must identify the source condition node, not null");
// SourceName: the full RawPath — unique across devices, matching SourceNode/ConditionId. The leaf
// name (HR200) is deliberately NOT used here: it collides across PLCs and is carried by ConditionName.
fields[SourceNameIndex].Value.ShouldBe(RawAlarmPath,
"SourceName must carry the unique RawPath, not null");
fields[SourceNameIndex].Value.ShouldNotBe(LeafName,
"SourceName is deliberately the unique RawPath, not the ambiguous leaf name");
// The rest of the standard envelope still arrives intact (guards against a regression that
// populated identity at the cost of the existing fields).
fields[TimeIndex].Value.ShouldBeOfType<DateTime>();
((LocalizedText)fields[MessageIndex].Value).Text.ShouldBe(AlarmMessage);
fields[SeverityIndex].Value.ShouldBe((ushort)700);
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
/// equipment folder wired as a notifier (the production topology).</summary>
private static void WireCondition(SdkAddressSpaceSink sink)
{
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "plc", realm: AddressSpaceRealm.Raw);
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, LeafName, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
}
private static AlarmConditionSnapshot ActiveSnapshot() =>
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
{
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = source,
AttributeId = Attributes.EventNotifier,
Filter = BuildEventFilter(),
QueueSize = 100,
SamplingInterval = 0,
};
subscription.AddItem(item);
return item;
}
/// <summary>ScadaBridge's exact select clause: [EventType, SourceNode, SourceName, Time, Message, Severity].</summary>
private static EventFilter BuildEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceNode);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.SourceName);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Time);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Severity);
return filter;
}
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
/// server events / refresh brackets never count.</summary>
private sealed class EventCollector
{
private readonly object _gate = new();
private readonly List<VariantCollection> _fields = new();
public IReadOnlyList<VariantCollection> Fields
{
get { lock (_gate) return _fields.ToList(); }
}
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > MessageIndex &&
e.EventFields[MessageIndex].Value is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_fields.Add(e.EventFields);
}
}
}
}
}
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
int port, string pkiRoot, string appUri, CancellationToken ct)
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = appUri,
ApplicationUri = appUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
return (server, host);
}
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.AlarmEventIdentityFieldsClient",
ApplicationUri = $"urn:OtOpcUa.AlarmEventIdentityFieldsClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "AlarmEventIdentityFieldDeliveryTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private static void SafeDelete(string dir)
{
if (Directory.Exists(dir))
{
try { Directory.Delete(dir, recursive: true); }
catch { /* best-effort */ }
}
}
}
@@ -0,0 +1,188 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Issue #473 — every materialised Part 9 condition must carry the mandatory <c>BaseEventType</c>
/// identity fields <c>EventType</c> / <c>SourceNode</c> / <c>SourceName</c>. The SDK does NOT synthesise
/// them on this path: <see cref="NodeState.Create"/> initialises from the type's embedded definition,
/// which declares all three as mandatory children with NO default value, and the auto-filling
/// <see cref="BaseEventState.Initialize(ISystemContext, NodeState, EventSeverity, LocalizedText)"/>
/// overload (which would set SourceNode/SourceName from a source node) is only used for transient
/// events. <c>ReportEvent</c> and <c>InstanceStateSnapshot</c> copy the children verbatim and synthesise
/// nothing — so a field left null at materialise arrives null on the wire on EVERY condition event.
/// <para>
/// The contract locked in here (see <c>docs/AlarmTracking.md</c>): the condition node IS the source
/// node — a native-alarm raw tag materialises ONLY a condition (no separate value variable), so
/// <c>SourceNode</c> self-references the condition's own NodeId and <c>SourceName</c> carries the
/// same identifying id string (<c>alarmNodeId</c>: the RawPath for native, the ScriptedAlarmId for
/// scripted), matching <c>ConditionId</c>. The leaf/display name stays on <c>ConditionName</c>, so
/// no information is lost by SourceName carrying the unique id rather than the ambiguous leaf.
/// </para>
/// </summary>
public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string RawDeviceFolder = "Plant/Modbus/dev1";
private const string RawAlarmPath = "Plant/Modbus/dev1/HR200";
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
/// remains on ConditionName so the short name is still available to clients.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Native_condition_carries_EventType_SourceNode_and_SourceName()
{
await using var host = await BootAsync();
var nm = host.Nm;
var rawNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw);
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Raw, isNative: true);
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
condition.ShouldNotBeNull();
// EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
// where-clause) can resolve the type.
condition.EventType.ShouldNotBeNull();
condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
// SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
// for an alarm-bearing raw tag). Equal to ConditionId.
condition.SourceNode.ShouldNotBeNull();
condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
// SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
condition.SourceName.ShouldNotBeNull();
condition.SourceName.Value.ShouldBe(RawAlarmPath);
// The leaf name is NOT lost — it stays on ConditionName.
condition.ConditionName!.Value.ShouldBe("HR200");
}
/// <summary>A SCRIPTED condition (UNS realm, ConditionId = ScriptedAlarmId) follows the SAME uniform rule:
/// SourceNode = its own NodeId, SourceName = the ScriptedAlarmId. Scripted and native conditions share the
/// materialise path, so neither may regress independently.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Scripted_condition_carries_EventType_SourceNode_and_SourceName()
{
await using var host = await BootAsync();
var nm = host.Nm;
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Station 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("tank-overflow", "eq-1", "Tank Overflow", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("tank-overflow", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
condition.SourceNode!.Value.ShouldBe(new NodeId("tank-overflow", unsNs));
condition.SourceName!.Value.ShouldBe("tank-overflow");
// The human-readable name stays on ConditionName.
condition.ConditionName!.Value.ShouldBe("Tank Overflow");
}
/// <summary>EventType tracks the ACTUAL materialised type, not a hardcoded constant: an unknown alarm type
/// falls back to the base <see cref="AlarmConditionState"/> and its EventType must report that base type
/// rather than a subtype the node does not have.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task EventType_reflects_the_fallback_base_type_for_an_unknown_alarm_type()
{
await using var host = await BootAsync();
var nm = host.Nm;
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Station 2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-x", "eq-2", "Generic", "LimitAlarm", 500,
realm: AddressSpaceRealm.Uns, isNative: false);
var condition = nm.TryGetAlarmCondition("alm-x", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base-type fallback
condition.EventType!.Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
}
/// <summary>The identity fields survive a re-materialise that DROPS and re-creates the node (the native↔scripted
/// kind-swap path), so a redeploy can never leave a condition emitting null identity.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Identity_fields_survive_a_kind_swap_rematerialise()
{
await using var host = await BootAsync();
var nm = host.Nm;
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Station 3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: false);
// Kind swap (scripted → native) drops the prior instance and re-creates it.
nm.MaterialiseAlarmCondition("alm-swap", "eq-3", "Swap", "OffNormalAlarm", 700,
realm: AddressSpaceRealm.Uns, isNative: true);
var condition = nm.TryGetAlarmCondition("alm-swap", AddressSpaceRealm.Uns);
condition.ShouldNotBeNull();
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
condition.SourceNode!.Value.ShouldBe(new NodeId("alm-swap", unsNs));
condition.SourceName!.Value.ShouldBe("alm-swap");
}
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
{
public OtOpcUaNodeManager Nm { get; } = nm;
public ValueTask DisposeAsync() => host.DisposeAsync();
}
private async Task<BootedServer> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.AlarmSourceFieldsTest",
ApplicationUri = $"urn:OtOpcUa.AlarmSourceFieldsTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return new BootedServer(host, server.NodeManager!);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort */ }
}
}
}