feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
v2-ci / unit-tests (push) Failing after 1h8m43s
v2-ci / unit-tests (push) Failing after 1h8m43s
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:
1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
held ONLY in the encrypted Secrets store
(otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
operator_user. Refusals are excluded from the resilience retry/breaker.
- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
acknowledger scrubs it from every gateway message it echoes and withholds
any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
path is fire-and-forget after the Part 9 method returns).
Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.
Closes #749
This commit is contained in:
@@ -220,8 +220,8 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
|
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.2.1" />
|
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.2.1" />
|
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
|
||||||
|
|||||||
@@ -85,6 +85,42 @@ Project root files:
|
|||||||
- **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric).
|
- **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric).
|
||||||
- **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`.
|
- **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`.
|
||||||
- **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag).
|
- **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag).
|
||||||
|
- **`AlarmAck`** (optional, lmxopcua#749) — `FallbackUser`, `FallbackDomain`, `FallbackPasswordSecretRef`. See [Alarm acknowledge credential](#alarm-acknowledge-credential).
|
||||||
|
|
||||||
|
### Alarm acknowledge credential
|
||||||
|
|
||||||
|
mxaccessgw `488649c`+ (client/contracts `0.3.0`) acknowledges a native alarm by running
|
||||||
|
`AuthenticateUser` as the operator and writing the comment to `<alarm>.AckMsg`, so the Historian
|
||||||
|
`ACK_ALM` row records a real Galaxy account. `AcknowledgeAlarmRequest` therefore carries
|
||||||
|
`operator_user` + `operator_domain` + `operator_password`, and the gateway rejects an empty
|
||||||
|
`operator_user`. The OPC UA data plane is machine-to-machine (API key or anonymous) and carries no
|
||||||
|
Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowledge:
|
||||||
|
|
||||||
|
1. **The API key's own Galaxy ack identity** — set on AdminUI `/apikeys` ("Ack identity"). Stored only
|
||||||
|
in the encrypted Secrets store as `otopcua/apikeys/<keyId>/galaxy-ack-user` (`DOMAIN\user` or
|
||||||
|
`user`) and `otopcua/apikeys/<keyId>/galaxy-ack-password` (`GalaxyAckIdentitySecrets`) — never in
|
||||||
|
the config DB or the deployment artifact. A key with a user but no password secret is **refused**,
|
||||||
|
not silently downgraded to the fallback.
|
||||||
|
2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` /
|
||||||
|
`FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous
|
||||||
|
sessions. The password ref must be `secret:NAME` / `${secret:NAME}` (production), `env:NAME`,
|
||||||
|
`file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig.
|
||||||
|
3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the
|
||||||
|
gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway
|
||||||
|
health). ⚠️ The native ack path is fire-and-forget after the Part 9 method returns, so the OPC UA
|
||||||
|
call itself still returns `Good` and the refusal surfaces as a `DriverInstance` warning naming the
|
||||||
|
fix — a synchronous status / revert is #760.
|
||||||
|
|
||||||
|
The comment written to `AckMsg` is suffixed with the OPC UA identity —
|
||||||
|
`"<comment> [OPC UA: <principal>, key <keyId>]"` or `"[OPC UA: anonymous]"` — so the client is not
|
||||||
|
lost when the Historian's Operator is a shared fallback account. The password is never logged: the
|
||||||
|
acknowledger scrubs it from every gateway message it echoes and `GalaxyAckCredential.ToString()`
|
||||||
|
omits it.
|
||||||
|
|
||||||
|
⚠️ **Secrets are node-local** (optionally Akka-replicated within one mesh). The per-key secrets and
|
||||||
|
the fallback secret must exist on the node that serves the OPC UA session and hosts the Galaxy
|
||||||
|
driver; on a split admin/driver topology create them on the driver nodes too (`/admin/secrets` or
|
||||||
|
the `secret` CLI) under exactly those names.
|
||||||
|
|
||||||
Full per-field descriptions live in `Config/GalaxyDriverOptions.cs`. The full JSON skeleton is reproduced in [docs/v2/driver-specs.md §1](../v2/driver-specs.md).
|
Full per-field descriptions live in `Config/GalaxyDriverOptions.cs`. The full JSON skeleton is reproduced in [docs/v2/driver-specs.md §1](../v2/driver-specs.md).
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,14 @@ of a credential was wrong.
|
|||||||
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
|
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
|
||||||
`Enabled` takes effect on a node only once a new artifact reaches it.
|
`Enabled` takes effect on a node only once a new artifact reaches it.
|
||||||
|
|
||||||
|
**Galaxy alarm-ack identity per key (lmxopcua#749).** mxaccessgw acknowledges Galaxy alarms as a real
|
||||||
|
Galaxy account, so a key can carry an optional Galaxy ack identity (AdminUI `/apikeys` → "Ack
|
||||||
|
identity"). It is held only in the encrypted Secrets store
|
||||||
|
(`otopcua/apikeys/<keyId>/galaxy-ack-{user,password}`), never in the ConfigDb or the artifact; keys
|
||||||
|
without one and anonymous sessions use the Galaxy driver's `AlarmAck` fallback account, and with
|
||||||
|
neither the ack is refused before it reaches the gateway. Deleting a key on `/apikeys` removes its
|
||||||
|
identity. See [docs/drivers/Galaxy.md § Alarm acknowledge credential](drivers/Galaxy.md#alarm-acknowledge-credential).
|
||||||
|
|
||||||
### Anonymous is a normal principal
|
### Anonymous is a normal principal
|
||||||
|
|
||||||
Anonymous sessions are **admitted by default** (`OpcUa:AllowAnonymous`, default `true` — flipping the
|
Anonymous sessions are **admitted by default** (`OpcUa:AllowAnonymous`, default `true` — flipping the
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown by a driver that deliberately refuses an operation before contacting its backend — a
|
||||||
|
/// configuration / authorization verdict (e.g. no Galaxy ack credential is available for the
|
||||||
|
/// caller, lmxopcua#749), not a transport or device fault.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The resilience pipeline neither retries it nor counts it toward the circuit breaker: a refusal
|
||||||
|
/// says nothing about the backend's health, so letting five misconfigured acks open the breaker
|
||||||
|
/// would block every correctly-configured caller on the same host.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverOperationRefusedException : InvalidOperationException
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="DriverOperationRefusedException"/> class.</summary>
|
||||||
|
/// <param name="message">Why the operation was refused. Must never contain a secret.</param>
|
||||||
|
public DriverOperationRefusedException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,11 +49,17 @@ public interface IAlarmSubscriptionHandle
|
|||||||
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
|
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
|
||||||
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system.</param>
|
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system.</param>
|
||||||
/// <param name="OperatorUser">Authenticated principal performing the ack; null on the raw/non-OPC-UA path.</param>
|
/// <param name="OperatorUser">Authenticated principal performing the ack; null on the raw/non-OPC-UA path.</param>
|
||||||
|
/// <param name="ApiKeyId">
|
||||||
|
/// Public id of the API key the OPC UA session authenticated with; null for anonymous / non-key paths.
|
||||||
|
/// Not a secret. Lets a driver that must acknowledge upstream as a real account (Galaxy, lmxopcua#749)
|
||||||
|
/// look up the identity mapped to that key.
|
||||||
|
/// </param>
|
||||||
public sealed record AlarmAcknowledgeRequest(
|
public sealed record AlarmAcknowledgeRequest(
|
||||||
string SourceNodeId,
|
string SourceNodeId,
|
||||||
string ConditionId,
|
string ConditionId,
|
||||||
string? Comment,
|
string? Comment,
|
||||||
string? OperatorUser = null);
|
string? OperatorUser = null,
|
||||||
|
string? ApiKeyId = null);
|
||||||
|
|
||||||
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
|
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
|
||||||
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
|
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ public sealed class DriverResiliencePipelineBuilder
|
|||||||
UseJitter = true,
|
UseJitter = true,
|
||||||
Delay = TimeSpan.FromMilliseconds(100),
|
Delay = TimeSpan.FromMilliseconds(100),
|
||||||
MaxDelay = TimeSpan.FromSeconds(5),
|
MaxDelay = TimeSpan.FromSeconds(5),
|
||||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException and not DriverOperationRefusedException),
|
||||||
};
|
};
|
||||||
if (tracker is not null || logger is not null)
|
if (tracker is not null || logger is not null)
|
||||||
{
|
{
|
||||||
@@ -173,7 +173,7 @@ public sealed class DriverResiliencePipelineBuilder
|
|||||||
MinimumThroughput = Math.Max(ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold, policy.BreakerFailureThreshold),
|
MinimumThroughput = Math.Max(ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold, policy.BreakerFailureThreshold),
|
||||||
SamplingDuration = TimeSpan.FromSeconds(30),
|
SamplingDuration = TimeSpan.FromSeconds(30),
|
||||||
BreakDuration = TimeSpan.FromSeconds(15),
|
BreakDuration = TimeSpan.FromSeconds(15),
|
||||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException and not DriverOperationRefusedException),
|
||||||
};
|
};
|
||||||
if (tracker is not null || logger is not null)
|
if (tracker is not null || logger is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Secrets-store naming convention for an API key's Galaxy alarm-ack identity (lmxopcua#749).
|
||||||
|
/// Shared by the AdminUI <c>/api-keys</c> page (writer) and the Galaxy driver (reader) so the two can
|
||||||
|
/// never disagree on a name.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both halves live in the encrypted <c>ZB.MOM.WW.Secrets</c> store — nothing about the identity is
|
||||||
|
/// written to the config DB or the deployment artifact. The identity secret holds the Galaxy account
|
||||||
|
/// as <c>DOMAIN\user</c> or bare <c>user</c>; the password secret holds the password (may be empty —
|
||||||
|
/// Galaxy accounts can have empty passwords; an <i>absent</i> password secret is a refusal).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ Secrets are node-local (optionally Akka-replicated within one mesh). The secret must exist on
|
||||||
|
/// the node that serves the OPC UA session and runs the Galaxy driver; on a split admin/driver
|
||||||
|
/// topology create it on the driver nodes too (their <c>/admin/secrets</c> page or the
|
||||||
|
/// <c>secret</c> CLI) under exactly these names.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class GalaxyAckIdentitySecrets
|
||||||
|
{
|
||||||
|
/// <summary>Common prefix for every per-key secret.</summary>
|
||||||
|
public const string Prefix = "otopcua/apikeys/";
|
||||||
|
|
||||||
|
/// <summary>Secret holding the key's Galaxy account (<c>DOMAIN\user</c> or <c>user</c>).</summary>
|
||||||
|
/// <param name="apiKeyId">The public API key id.</param>
|
||||||
|
/// <returns>The secret name.</returns>
|
||||||
|
public static string UserSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-user";
|
||||||
|
|
||||||
|
/// <summary>Secret holding the key's Galaxy account password.</summary>
|
||||||
|
/// <param name="apiKeyId">The public API key id.</param>
|
||||||
|
/// <returns>The secret name.</returns>
|
||||||
|
public static string PasswordSecretName(string apiKeyId) => $"{Prefix}{Normalize(apiKeyId)}/galaxy-ack-password";
|
||||||
|
|
||||||
|
/// <summary>Formats a user + optional domain as the identity secret value.</summary>
|
||||||
|
/// <param name="user">The Galaxy user name.</param>
|
||||||
|
/// <param name="domain">The optional domain.</param>
|
||||||
|
/// <returns><c>DOMAIN\user</c> or <c>user</c>.</returns>
|
||||||
|
public static string FormatIdentity(string user, string? domain) =>
|
||||||
|
string.IsNullOrWhiteSpace(domain) ? user.Trim() : $"{domain.Trim()}\\{user.Trim()}";
|
||||||
|
|
||||||
|
/// <summary>Splits an identity secret value into user + domain on the first backslash.</summary>
|
||||||
|
/// <param name="identity">The stored identity value.</param>
|
||||||
|
/// <returns>The user (trimmed) and the domain (empty when none).</returns>
|
||||||
|
public static (string User, string Domain) ParseIdentity(string identity)
|
||||||
|
{
|
||||||
|
var value = (identity ?? string.Empty).Trim();
|
||||||
|
var slash = value.IndexOf('\\');
|
||||||
|
return slash < 0
|
||||||
|
? (value, string.Empty)
|
||||||
|
: (value[(slash + 1)..].Trim(), value[..slash].Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string apiKeyId)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(apiKeyId);
|
||||||
|
var id = apiKeyId.Trim().ToLowerInvariant();
|
||||||
|
foreach (var c in id)
|
||||||
|
{
|
||||||
|
// Key ids are lowercase hex (ApiKeySecret.Generate); refuse anything that could change the
|
||||||
|
// secret-name path shape (a '/', '..') rather than trusting the caller.
|
||||||
|
if (!char.IsAsciiLetterOrDigit(c))
|
||||||
|
throw new ArgumentException($"API key id '{apiKeyId}' is not a valid key id.", nameof(apiKeyId));
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,47 @@ public sealed record GalaxyDriverOptions(
|
|||||||
/// address (identity), preserving pre-v3 behaviour for the live-browse discovery path.
|
/// address (identity), preserving pre-v3 behaviour for the live-browse discovery path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-credential policy for native alarm acknowledgement (lmxopcua#749). mxaccessgw
|
||||||
|
/// (488649c+) acknowledges by authenticating as the operator and writing <c><alarm>.AckMsg</c>,
|
||||||
|
/// so every ack needs a Galaxy user + password. See <see cref="GalaxyAlarmAckOptions"/>.
|
||||||
|
/// </summary>
|
||||||
|
public GalaxyAlarmAckOptions AlarmAck { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the Galaxy credential for a native alarm acknowledge comes from (lmxopcua#749).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA data plane is machine-to-machine (API key or anonymous), so the session carries no
|
||||||
|
/// Galaxy password. Resolution order per acknowledge:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>The authenticating API key's own Galaxy ack identity, set in AdminUI <c>/api-keys</c> and
|
||||||
|
/// held in the Secrets store under <see cref="GalaxyAckIdentitySecrets"/> names.</item>
|
||||||
|
/// <item>This fallback account — used by keys with no identity and by anonymous sessions.</item>
|
||||||
|
/// <item>Neither → the acknowledge is refused; nothing is sent to the gateway (never an empty
|
||||||
|
/// <c>operator_user</c>).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="FallbackPasswordSecretRef"/> must be a reference — <c>secret:NAME</c> (production),
|
||||||
|
/// <c>env:NAME</c>, <c>file:PATH</c> or <c>dev:VALUE</c> (rigs only). A bare literal is rejected so a
|
||||||
|
/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="FallbackUser">Galaxy user the fallback acknowledges as; null/empty = no fallback.</param>
|
||||||
|
/// <param name="FallbackDomain">Optional domain for <paramref name="FallbackUser"/> (sent as <c>operator_domain</c>).</param>
|
||||||
|
/// <param name="FallbackPasswordSecretRef">Reference to the fallback account's password.</param>
|
||||||
|
public sealed record GalaxyAlarmAckOptions(
|
||||||
|
string? FallbackUser = null,
|
||||||
|
string? FallbackDomain = null,
|
||||||
|
string? FallbackPasswordSecretRef = null)
|
||||||
|
{
|
||||||
|
/// <summary>True when a fallback account is configured.</summary>
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
|
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ public sealed class GalaxyDriver
|
|||||||
// wiring without a running gateway. This driver bridges the feed's OnAlarmTransition
|
// wiring without a running gateway. This driver bridges the feed's OnAlarmTransition
|
||||||
// onto IAlarmSource.OnAlarmEvent.
|
// onto IAlarmSource.OnAlarmEvent.
|
||||||
private IGalaxyAlarmAcknowledger? _alarmAcknowledger;
|
private IGalaxyAlarmAcknowledger? _alarmAcknowledger;
|
||||||
|
// lmxopcua#749: resolves the Galaxy account each acknowledge authenticates as (API key identity →
|
||||||
|
// fallback account → refused). Built in the ctor — it needs only options + the secret resolver.
|
||||||
|
private readonly IGalaxyAckCredentialResolver _ackCredentialResolver;
|
||||||
private IGalaxyAlarmFeed? _alarmFeed;
|
private IGalaxyAlarmFeed? _alarmFeed;
|
||||||
private readonly Lock _alarmHandlersLock = new();
|
private readonly Lock _alarmHandlersLock = new();
|
||||||
private readonly Lock _alarmFeedLock = new();
|
private readonly Lock _alarmFeedLock = new();
|
||||||
@@ -300,6 +303,10 @@ public sealed class GalaxyDriver
|
|||||||
/// <see cref="InitializeAsync"/> seeds the watcher from it and every rediscovery re-seeds
|
/// <see cref="InitializeAsync"/> seeds the watcher from it and every rediscovery re-seeds
|
||||||
/// (production builds a <see cref="GatewayGalaxyPlatformSource"/> over the owned repository client).
|
/// (production builds a <see cref="GatewayGalaxyPlatformSource"/> over the owned repository client).
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="ackCredentialResolver">
|
||||||
|
/// Optional ack-credential seam (lmxopcua#749). Defaults to <see cref="GalaxyAckCredentialResolver"/>
|
||||||
|
/// over <see cref="GalaxyDriverOptions.AlarmAck"/> and <paramref name="secretResolver"/>.
|
||||||
|
/// </param>
|
||||||
internal GalaxyDriver(
|
internal GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
@@ -312,7 +319,8 @@ public sealed class GalaxyDriver
|
|||||||
ILogger<GalaxyDriver>? logger = null,
|
ILogger<GalaxyDriver>? logger = null,
|
||||||
ISecretResolver? secretResolver = null,
|
ISecretResolver? secretResolver = null,
|
||||||
IGalaxyDeployWatchSource? deployWatchSource = null,
|
IGalaxyDeployWatchSource? deployWatchSource = null,
|
||||||
IGalaxyPlatformSource? platformSource = null)
|
IGalaxyPlatformSource? platformSource = null,
|
||||||
|
IGalaxyAckCredentialResolver? ackCredentialResolver = null)
|
||||||
{
|
{
|
||||||
_deployWatchSource = deployWatchSource;
|
_deployWatchSource = deployWatchSource;
|
||||||
_platformSource = platformSource;
|
_platformSource = platformSource;
|
||||||
@@ -328,6 +336,8 @@ public sealed class GalaxyDriver
|
|||||||
_subscriber = subscriber;
|
_subscriber = subscriber;
|
||||||
_alarmAcknowledger = alarmAcknowledger;
|
_alarmAcknowledger = alarmAcknowledger;
|
||||||
_alarmFeed = alarmFeed;
|
_alarmFeed = alarmFeed;
|
||||||
|
_ackCredentialResolver = ackCredentialResolver
|
||||||
|
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
|
||||||
|
|
||||||
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
|
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
|
||||||
// are authored (live-browse path), leaving every translation an identity no-op.
|
// are authored (live-browse path), leaving every translation an identity no-op.
|
||||||
@@ -1927,16 +1937,55 @@ public sealed class GalaxyDriver
|
|||||||
? ack.ConditionId
|
? ack.ConditionId
|
||||||
: ack.SourceNodeId;
|
: ack.SourceNodeId;
|
||||||
var alarmFullReference = ToAttributeRef(conditionReference);
|
var alarmFullReference = ToAttributeRef(conditionReference);
|
||||||
|
|
||||||
|
// lmxopcua#749: the gateway acknowledges as a real Galaxy account (AuthenticateUser +
|
||||||
|
// AckMsg write), and the OPC UA session has no Galaxy password. Resolve the account mapped
|
||||||
|
// to the session's API key, else the driver's fallback account; with neither, refuse —
|
||||||
|
// nothing is sent, and never with an empty operator_user.
|
||||||
|
var resolution = await _ackCredentialResolver
|
||||||
|
.ResolveAsync(ack.ApiKeyId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (resolution.Credential is not { } credential)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Galaxy driver {DriverInstanceId}: acknowledge of {AlarmRef} by OPC UA principal {Principal} (key {ApiKeyId}) refused: {Reason}",
|
||||||
|
_driverInstanceId, alarmFullReference, PrincipalOf(ack), ack.ApiKeyId ?? "none", resolution.RefusalReason);
|
||||||
|
throw new DriverOperationRefusedException(
|
||||||
|
$"Galaxy acknowledge of '{alarmFullReference}' refused: {resolution.RefusalReason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Historian records the Galaxy account; keep the OPC UA principal + key id with the ack
|
||||||
|
// so it isn't lost when the account is a shared fallback.
|
||||||
|
var comment = AnnotateAckComment(ack.Comment, ack.OperatorUser, ack.ApiKeyId);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Galaxy driver {DriverInstanceId}: acknowledging {AlarmRef} as Galaxy user {GalaxyUser} ({CredentialSource}) for OPC UA principal {Principal} (key {ApiKeyId})",
|
||||||
|
_driverInstanceId, alarmFullReference, credential.DisplayName, credential.Source,
|
||||||
|
PrincipalOf(ack), ack.ApiKeyId ?? "none");
|
||||||
|
|
||||||
await _alarmAcknowledger.AcknowledgeAsync(
|
await _alarmAcknowledger.AcknowledgeAsync(
|
||||||
alarmFullReference,
|
alarmFullReference, comment, credential, cancellationToken).ConfigureAwait(false);
|
||||||
ack.Comment ?? string.Empty,
|
|
||||||
// Honor the authenticated principal carried on the request when present;
|
|
||||||
// fall back to empty (server-side ACL fills this from the OPC UA session).
|
|
||||||
operatorUser: ack.OperatorUser ?? string.Empty,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string PrincipalOf(AlarmAcknowledgeRequest ack) =>
|
||||||
|
string.IsNullOrWhiteSpace(ack.OperatorUser) ? "anonymous" : ack.OperatorUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the OPC UA identity to the ack comment written to <c>AckMsg</c> (lmxopcua#749), e.g.
|
||||||
|
/// <c>"shift handover [OPC UA: mes-line1, key 0a1b…]"</c> or <c>"[OPC UA: anonymous]"</c>. The
|
||||||
|
/// Historian's Operator column holds the Galaxy account (possibly a shared fallback), so this is
|
||||||
|
/// the durable record of which client acknowledged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="comment">The operator's comment, or null.</param>
|
||||||
|
/// <param name="principal">The OPC UA principal, or null/empty for anonymous.</param>
|
||||||
|
/// <param name="apiKeyId">The authenticating key id, or null.</param>
|
||||||
|
/// <returns>The annotated comment.</returns>
|
||||||
|
internal static string AnnotateAckComment(string? comment, string? principal, string? apiKeyId)
|
||||||
|
{
|
||||||
|
var who = string.IsNullOrWhiteSpace(principal) ? "anonymous" : principal.Trim();
|
||||||
|
var tag = string.IsNullOrWhiteSpace(apiKeyId) ? $"[OPC UA: {who}]" : $"[OPC UA: {who}, key {apiKeyId}]";
|
||||||
|
return string.IsNullOrWhiteSpace(comment) ? tag : $"{comment.TrimEnd()} {tag}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Receives <see cref="GalaxyAlarmTransition"/> events from the gateway alarm
|
/// Receives <see cref="GalaxyAlarmTransition"/> events from the gateway alarm
|
||||||
/// feed and reshapes them into <see cref="AlarmEventArgs"/> for OPC UA-side
|
/// feed and reshapes them into <see cref="AlarmEventArgs"/> for OPC UA-side
|
||||||
|
|||||||
@@ -102,11 +102,17 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
// carrying the camelCase "attributeRef" + WriteIdempotent flag). The driver builds its
|
// carrying the camelCase "attributeRef" + WriteIdempotent flag). The driver builds its
|
||||||
// RawPath → attributeRef table from these. Empty when the config omits them.
|
// RawPath → attributeRef table from these. Empty when the config omits them.
|
||||||
RawTags = dto.RawTags ?? [],
|
RawTags = dto.RawTags ?? [],
|
||||||
|
AlarmAck = new GalaxyAlarmAckOptions(
|
||||||
|
FallbackUser: NullIfBlank(dto.AlarmAck?.FallbackUser),
|
||||||
|
FallbackDomain: NullIfBlank(dto.AlarmAck?.FallbackDomain),
|
||||||
|
FallbackPasswordSecretRef: NullIfBlank(dto.AlarmAck?.FallbackPasswordSecretRef)),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -131,6 +137,19 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
/// WriteIdempotent flag. Bound straight through to <see cref="GalaxyDriverOptions.RawTags"/>.
|
/// WriteIdempotent flag. Bound straight through to <see cref="GalaxyDriverOptions.RawTags"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<RawTagEntry>? RawTags { get; init; }
|
public List<RawTagEntry>? RawTags { get; init; }
|
||||||
|
/// <summary>Gets or sets the native alarm-ack credential policy (lmxopcua#749).</summary>
|
||||||
|
public AlarmAckDto? AlarmAck { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Native alarm-ack credential section (lmxopcua#749).</summary>
|
||||||
|
internal sealed class AlarmAckDto
|
||||||
|
{
|
||||||
|
/// <summary>Gets or sets the fallback Galaxy user.</summary>
|
||||||
|
public string? FallbackUser { get; init; }
|
||||||
|
/// <summary>Gets or sets the fallback user's domain.</summary>
|
||||||
|
public string? FallbackDomain { get; init; }
|
||||||
|
/// <summary>Gets or sets the fallback password reference (secret:/env:/file:/dev:).</summary>
|
||||||
|
public string? FallbackPasswordSecretRef { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gateway configuration section.</summary>
|
/// <summary>Gateway configuration section.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
/// <summary>Where a <see cref="GalaxyAckCredential"/> came from.</summary>
|
||||||
|
internal enum GalaxyAckCredentialSource
|
||||||
|
{
|
||||||
|
/// <summary>The authenticating API key's own Galaxy ack identity (AdminUI <c>/api-keys</c>).</summary>
|
||||||
|
ApiKey,
|
||||||
|
|
||||||
|
/// <summary>The driver instance's configured fallback ack account.</summary>
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). The gateway runs
|
||||||
|
/// <c>AuthenticateUser(domain\user, password)</c> and writes <c><alarm>.AckMsg</c> with that user,
|
||||||
|
/// so the Historian records this account as the operator.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ⚠️ Carries a password. A class, not a record, so the compiler-generated <c>ToString</c> /
|
||||||
|
/// <c>PrintMembers</c> can never print it; <see cref="ToString"/> is overridden to omit it. Never log,
|
||||||
|
/// format or put an instance in an exception message other than via <see cref="ToString"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class GalaxyAckCredential
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredential"/> class.</summary>
|
||||||
|
/// <param name="user">Galaxy user; must be non-empty (the gateway rejects an empty operator_user).</param>
|
||||||
|
/// <param name="domain">Domain, or empty.</param>
|
||||||
|
/// <param name="password">Password (may be empty — Galaxy accounts can have empty passwords).</param>
|
||||||
|
/// <param name="source">Where the credential came from.</param>
|
||||||
|
public GalaxyAckCredential(string user, string? domain, string? password, GalaxyAckCredentialSource source)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(user);
|
||||||
|
User = user.Trim();
|
||||||
|
Domain = domain?.Trim() ?? string.Empty;
|
||||||
|
Password = password ?? string.Empty;
|
||||||
|
Source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the Galaxy user name.</summary>
|
||||||
|
public string User { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the domain, or empty.</summary>
|
||||||
|
public string Domain { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the password. Secret.</summary>
|
||||||
|
public string Password { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets where the credential came from.</summary>
|
||||||
|
public GalaxyAckCredentialSource Source { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the <c>DOMAIN\user</c> display form (no password).</summary>
|
||||||
|
public string DisplayName => Domain.Length == 0 ? User : $"{Domain}\\{User}";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string ToString() => $"{DisplayName} ({Source}, password redacted)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Outcome of resolving the Galaxy ack credential for one acknowledge.</summary>
|
||||||
|
/// <param name="Credential">The credential, or null when refused.</param>
|
||||||
|
/// <param name="RefusalReason">Why the acknowledge is refused (never contains a secret), or null.</param>
|
||||||
|
internal sealed record GalaxyAckCredentialResolution(GalaxyAckCredential? Credential, string? RefusalReason)
|
||||||
|
{
|
||||||
|
/// <summary>Creates a successful resolution.</summary>
|
||||||
|
/// <param name="credential">The resolved credential.</param>
|
||||||
|
/// <returns>The resolution.</returns>
|
||||||
|
public static GalaxyAckCredentialResolution Resolved(GalaxyAckCredential credential) => new(credential, null);
|
||||||
|
|
||||||
|
/// <summary>Creates a refusal.</summary>
|
||||||
|
/// <param name="reason">Why; must not contain a secret.</param>
|
||||||
|
/// <returns>The resolution.</returns>
|
||||||
|
public static GalaxyAckCredentialResolution Refused(string reason) => new(null, reason);
|
||||||
|
|
||||||
|
// Explicit so the generated record ToString never reaches GalaxyAckCredential's members by any
|
||||||
|
// other path than its own redacting ToString.
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string ToString() =>
|
||||||
|
Credential is not null ? $"Resolved {Credential}" : $"Refused: {RefusalReason}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IGalaxyAckCredentialResolver"/>: the API key's own Galaxy ack identity
|
||||||
|
/// (Secrets store, <see cref="GalaxyAckIdentitySecrets"/>) → the driver's fallback account
|
||||||
|
/// (<see cref="GalaxyAlarmAckOptions"/>) → refused.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Resolves on every acknowledge rather than caching, so rotating a password or remapping a key takes
|
||||||
|
/// effect on the next ack; the Secrets resolver caches decrypted values itself. Nothing here logs a
|
||||||
|
/// password: log lines and refusal reasons name only users, key ids and secret NAMES.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||||
|
{
|
||||||
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly GalaxyAlarmAckOptions _options;
|
||||||
|
private readonly ISecretResolver _secrets;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
|
||||||
|
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
|
||||||
|
/// <param name="options">The driver's ack-credential options.</param>
|
||||||
|
/// <param name="secrets">The shared secret resolver.</param>
|
||||||
|
/// <param name="logger">Logger; never receives a secret.</param>
|
||||||
|
public GalaxyAckCredentialResolver(
|
||||||
|
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
|
||||||
|
{
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
_options = options ?? new GalaxyAlarmAckOptions();
|
||||||
|
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? keyNote = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKeyId))
|
||||||
|
{
|
||||||
|
var keyResult = await ResolveKeyIdentityAsync(apiKeyId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (keyResult.Credential is not null) return keyResult;
|
||||||
|
// A key whose identity is half-configured (user without password, unreadable secret) is
|
||||||
|
// refused outright — silently acknowledging as the fallback account would record the wrong
|
||||||
|
// operator for a key the administrator explicitly mapped.
|
||||||
|
if (keyResult.RefusalReason is not null) return keyResult;
|
||||||
|
keyNote = $"API key '{apiKeyId}' has no Galaxy ack identity";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_options.HasFallback)
|
||||||
|
{
|
||||||
|
return await ResolveFallbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"{keyNote ?? "the session is anonymous (no API key)"} and Galaxy driver '{_driverInstanceId}' has no " +
|
||||||
|
"AlarmAck.FallbackUser configured; the acknowledge was not sent. Map a Galaxy ack identity to the key " +
|
||||||
|
"in AdminUI /api-keys or configure the driver's fallback ack account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns Resolved, Refused (mapped but broken), or (null, null) = "no identity mapped".
|
||||||
|
private async Task<GalaxyAckCredentialResolution> ResolveKeyIdentityAsync(string apiKeyId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string userName, passwordName;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
userName = GalaxyAckIdentitySecrets.UserSecretName(apiKeyId);
|
||||||
|
passwordName = GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return GalaxyAckCredentialResolution.Refused($"API key id '{apiKeyId}' is malformed; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? identity;
|
||||||
|
string? password;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
identity = await _secrets.GetAsync(new SecretName(userName), ct).ConfigureAwait(false);
|
||||||
|
if (string.IsNullOrWhiteSpace(identity)) return new GalaxyAckCredentialResolution(null, null);
|
||||||
|
password = await _secrets.GetAsync(new SecretName(passwordName), ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The exception type only — a secrets-layer message is not ours to vouch for.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Galaxy driver {DriverInstanceId}: reading the Galaxy ack identity of API key {ApiKeyId} failed ({ExceptionType})",
|
||||||
|
_driverInstanceId, apiKeyId, ex.GetType().Name);
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"the Galaxy ack identity of API key '{apiKeyId}' could not be read from the Secrets store; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password is null)
|
||||||
|
{
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"API key '{apiKeyId}' has a Galaxy ack user but no password secret '{passwordName}'; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (user, domain) = GalaxyAckIdentitySecrets.ParseIdentity(identity);
|
||||||
|
if (user.Length == 0)
|
||||||
|
{
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"API key '{apiKeyId}' has a malformed Galaxy ack user secret '{userName}'; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return GalaxyAckCredentialResolution.Resolved(
|
||||||
|
new GalaxyAckCredential(user, domain, password, GalaxyAckCredentialSource.ApiKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<GalaxyAckCredentialResolution> ResolveFallbackAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var reference = _options.FallbackPasswordSecretRef;
|
||||||
|
if (string.IsNullOrWhiteSpace(reference))
|
||||||
|
{
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"Galaxy driver '{_driverInstanceId}' has AlarmAck.FallbackUser but no AlarmAck.FallbackPasswordSecretRef; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var password = await ResolvePasswordRefAsync(reference.Trim(), ct).ConfigureAwait(false);
|
||||||
|
if (password is null)
|
||||||
|
{
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"Galaxy driver '{_driverInstanceId}' fallback ack password reference '{DescribeRef(reference)}' did not resolve; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return GalaxyAckCredentialResolution.Resolved(new GalaxyAckCredential(
|
||||||
|
_options.FallbackUser!, _options.FallbackDomain, password, GalaxyAckCredentialSource.Fallback));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Galaxy driver {DriverInstanceId}: resolving the fallback ack password reference {Reference} failed ({ExceptionType})",
|
||||||
|
_driverInstanceId, DescribeRef(reference), ex.GetType().Name);
|
||||||
|
return GalaxyAckCredentialResolution.Refused(
|
||||||
|
$"Galaxy driver '{_driverInstanceId}' fallback ack password reference '{DescribeRef(reference)}' could not be resolved; the acknowledge was not sent.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a password reference. Accepts <c>secret:NAME</c>, <c>${secret:NAME}</c>, <c>env:NAME</c>,
|
||||||
|
/// <c>file:PATH</c> and <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
|
||||||
|
/// <see cref="FormatException"/> for a bare literal (a cleartext password in DriverConfig).
|
||||||
|
/// </summary>
|
||||||
|
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
|
||||||
|
{
|
||||||
|
reference = "secret:" + reference["${secret:".Length..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Environment.GetEnvironmentVariable(reference[4..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var path = reference[5..];
|
||||||
|
return File.Exists(path)
|
||||||
|
? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return reference[4..];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
|
||||||
|
internal static string DescribeRef(string reference)
|
||||||
|
{
|
||||||
|
var r = reference.Trim();
|
||||||
|
if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
|
||||||
|
if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<literal redacted>";
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-27
@@ -6,20 +6,35 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Production <see cref="IGalaxyAlarmAcknowledger"/> backed by the session-less
|
/// Production <see cref="IGalaxyAlarmAcknowledger"/> backed by the session-less
|
||||||
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC. The updated gateway routes
|
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC. The gateway routes acknowledgement
|
||||||
/// acknowledgement through its always-on central alarm monitor, so no worker
|
/// through its always-on central alarm monitor, so no worker session is involved.
|
||||||
/// session is involved — the driver supplies only the alarm reference, comment,
|
|
||||||
/// and operator principal.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A non-OK <see cref="ProtocolStatus"/> means the gateway never reached MXAccess
|
/// <para>
|
||||||
/// (transport / dispatch failure) and is surfaced as a thrown exception. A non-zero
|
/// <b>Operator-authenticated ack (mxaccessgw 488649c+, lmxopcua#749).</b> The gateway runs
|
||||||
/// native ack return code (<c>hresult</c>) means MXAccess itself rejected the ack;
|
/// <c>AuthenticateUser</c> with <c>operator_user</c>/<c>operator_domain</c>/<c>operator_password</c>
|
||||||
/// that is logged as a warning rather than thrown so a transient MXAccess hiccup
|
/// and writes the comment to <c><alarm>.AckMsg</c> as that user, so the request always
|
||||||
/// doesn't block the operator workflow — the operator can retry.
|
/// carries a real Galaxy account — never an empty <c>operator_user</c>, which the gateway rejects.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The password never leaves the request.</b> It is not logged, and every message this type
|
||||||
|
/// builds from gateway output is scrubbed of it (the gateway contract already keeps it out of
|
||||||
|
/// <c>ProtocolStatus.Message</c>; the scrub is defence in depth). The 0.3.0 client redacts it from
|
||||||
|
/// its own <c>MxGatewayException</c>s.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A non-OK <see cref="ProtocolStatus"/> means the gateway never reached MXAccess (transport /
|
||||||
|
/// dispatch / validation failure) and is surfaced as a thrown exception. A non-zero native ack
|
||||||
|
/// return code (<c>hresult</c> — now the <c>AuthenticateUser</c> HRESULT or the AckMsg write status)
|
||||||
|
/// means MXAccess rejected the ack; that is logged as a warning rather than thrown so a transient
|
||||||
|
/// MXAccess hiccup doesn't block the operator workflow — the operator can retry.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
|
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
|
||||||
{
|
{
|
||||||
|
/// <summary>Placeholder substituted for the password wherever gateway text is echoed.</summary>
|
||||||
|
internal const string Redacted = "***";
|
||||||
|
|
||||||
private readonly MxGatewayClient _client;
|
private readonly MxGatewayClient _client;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
@@ -36,38 +51,99 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
|
|||||||
public async Task AcknowledgeAsync(
|
public async Task AcknowledgeAsync(
|
||||||
string alarmFullReference,
|
string alarmFullReference,
|
||||||
string comment,
|
string comment,
|
||||||
string operatorUser,
|
GalaxyAckCredential credential,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = BuildRequest(alarmFullReference, comment, credential);
|
||||||
|
|
||||||
|
AcknowledgeAlarmReply reply;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reply = await _client.AcknowledgeAlarmAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException
|
||||||
|
&& credential.Password.Length > 0
|
||||||
|
&& ContainsSecret(ex, credential.Password))
|
||||||
|
{
|
||||||
|
// Defence in depth over the client's own redaction: never let an exception that echoes the
|
||||||
|
// password escape into the actor's warning log.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' failed at the gateway ({ex.GetType().Name}); "
|
||||||
|
+ "details withheld because they echoed the operator credential.");
|
||||||
|
}
|
||||||
|
|
||||||
|
EvaluateReply(reply, alarmFullReference, credential, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps an acknowledge onto the gateway request. Pure — pinned by unit tests.</summary>
|
||||||
|
/// <param name="alarmFullReference">The alarm reference.</param>
|
||||||
|
/// <param name="comment">The ack comment.</param>
|
||||||
|
/// <param name="credential">The Galaxy account to acknowledge as.</param>
|
||||||
|
/// <returns>The request.</returns>
|
||||||
|
internal static AcknowledgeAlarmRequest BuildRequest(
|
||||||
|
string alarmFullReference, string comment, GalaxyAckCredential credential)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(alarmFullReference);
|
ArgumentException.ThrowIfNullOrEmpty(alarmFullReference);
|
||||||
|
ArgumentNullException.ThrowIfNull(credential);
|
||||||
|
|
||||||
var reply = await _client.AcknowledgeAlarmAsync(
|
return new AcknowledgeAlarmRequest
|
||||||
new AcknowledgeAlarmRequest
|
{
|
||||||
{
|
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
||||||
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
AlarmFullReference = alarmFullReference,
|
||||||
AlarmFullReference = alarmFullReference,
|
Comment = comment ?? string.Empty,
|
||||||
Comment = comment ?? string.Empty,
|
OperatorUser = credential.User,
|
||||||
OperatorUser = operatorUser ?? string.Empty,
|
OperatorDomain = credential.Domain,
|
||||||
},
|
OperatorPassword = credential.Password,
|
||||||
cancellationToken).ConfigureAwait(false);
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the reply semantics: non-OK protocol status throws; a non-zero native code warns.
|
||||||
|
/// Every message is scrubbed of the password.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reply">The gateway reply.</param>
|
||||||
|
/// <param name="alarmFullReference">The alarm reference.</param>
|
||||||
|
/// <param name="credential">The credential the request carried.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
internal static void EvaluateReply(
|
||||||
|
AcknowledgeAlarmReply reply, string alarmFullReference, GalaxyAckCredential credential, ILogger logger)
|
||||||
|
{
|
||||||
// Protocol status — the gateway failed before MXAccess saw the ack. This is a
|
// Protocol status — the gateway failed before MXAccess saw the ack. This is a
|
||||||
// hard failure: the operator's request was not delivered at all.
|
// hard failure: the operator's request was not delivered at all.
|
||||||
if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok)
|
if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' failed at the gateway: "
|
$"Galaxy AcknowledgeAlarm for '{alarmFullReference}' as '{credential.DisplayName}' failed at the gateway: "
|
||||||
+ $"{proto.Code} {proto.Message}");
|
+ $"{proto.Code} {Scrub(proto.Message, credential.Password)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// hresult is the authoritative native ack return code (0 = success). It is
|
// hresult is the authoritative native ack return code (0 = success) — the AuthenticateUser HRESULT
|
||||||
// absent only on a worker protocol violation; with an OK protocol status a
|
// or the AckMsg write status. It is absent only on a worker protocol violation; with an OK protocol
|
||||||
// missing value is treated as success.
|
// status a missing value is treated as success.
|
||||||
if (reply.HasHresult && reply.Hresult != 0)
|
if (reply.HasHresult && reply.Hresult != 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Galaxy AcknowledgeAlarm for {AlarmRef} returned native ack failure code {Hresult}.",
|
"Galaxy AcknowledgeAlarm for {AlarmRef} as {GalaxyUser} returned native ack failure code {Hresult}.",
|
||||||
alarmFullReference, reply.Hresult);
|
alarmFullReference, credential.DisplayName, reply.Hresult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces every occurrence of <paramref name="secret"/> in <paramref name="text"/>.</summary>
|
||||||
|
/// <param name="text">Text that may echo the secret.</param>
|
||||||
|
/// <param name="secret">The secret; empty means nothing to scrub.</param>
|
||||||
|
/// <returns>The scrubbed text.</returns>
|
||||||
|
internal static string Scrub(string? text, string secret) =>
|
||||||
|
string.IsNullOrEmpty(text) || string.IsNullOrEmpty(secret)
|
||||||
|
? text ?? string.Empty
|
||||||
|
: text.Replace(secret, Redacted, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static bool ContainsSecret(Exception ex, string secret)
|
||||||
|
{
|
||||||
|
for (var e = ex; e is not null; e = e.InnerException)
|
||||||
|
{
|
||||||
|
if (e.Message.Contains(secret, StringComparison.Ordinal)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the Galaxy account a native alarm acknowledge authenticates as (lmxopcua#749). Seam so
|
||||||
|
/// <see cref="GalaxyDriver.AcknowledgeAsync"/> can be exercised without a Secrets store.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IGalaxyAckCredentialResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the credential for an acknowledge from a session that authenticated with
|
||||||
|
/// <paramref name="apiKeyId"/> (null = anonymous / no key). Never throws for a missing or
|
||||||
|
/// unreadable credential — that is a <see cref="GalaxyAckCredentialResolution.Refused"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="apiKeyId">The authenticating API key id, or null.</param>
|
||||||
|
/// <param name="cancellationToken">Cancels secret resolution.</param>
|
||||||
|
/// <returns>The resolved credential or the refusal reason.</returns>
|
||||||
|
Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -11,23 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|||||||
internal interface IGalaxyAlarmAcknowledger
|
internal interface IGalaxyAlarmAcknowledger
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forward a single alarm acknowledgement to the gateway. The gateway
|
/// Forward a single alarm acknowledgement to the gateway. The gateway (488649c+)
|
||||||
/// translates this to an MxAccess Acknowledge call against the worker's
|
/// authenticates <paramref name="credential"/> and writes <paramref name="comment"/>
|
||||||
/// session and returns the native MxStatus on the reply.
|
/// to <c><alarm>.AckMsg</c> as that user, so the Historian records it as the operator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="alarmFullReference">
|
/// <param name="alarmFullReference">
|
||||||
/// Fully-qualified alarm reference (e.g. <c>"Tank01.Level.HiHi"</c>).
|
/// Fully-qualified alarm reference (e.g. <c>"Tank01.Level.HiHi"</c>).
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="comment">Operator-supplied comment forwarded to MxAccess.</param>
|
/// <param name="comment">Ack comment written to <c>AckMsg</c>.</param>
|
||||||
/// <param name="operatorUser">
|
/// <param name="credential">
|
||||||
/// Operator principal performing the acknowledgement. Resolved from the
|
/// The Galaxy account to acknowledge as, resolved by <see cref="IGalaxyAckCredentialResolver"/>.
|
||||||
/// OPC UA session by the server-side ACL layer before reaching the driver.
|
/// Carries a password — implementations must keep it out of logs and exception text.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
|
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task AcknowledgeAsync(
|
Task AcknowledgeAsync(
|
||||||
string alarmFullReference,
|
string alarmFullReference,
|
||||||
string comment,
|
string comment,
|
||||||
string operatorUser,
|
GalaxyAckCredential credential,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets, lists and clears the Galaxy alarm-ack identity mapped to an OPC UA API key (lmxopcua#749).
|
||||||
|
/// The identity (<c>DOMAIN\user</c>) and its password are written to the encrypted Secrets store under
|
||||||
|
/// <see cref="GalaxyAckIdentitySecrets"/> names — never to the config DB or the deployment artifact.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A service rather than page logic because this repo has no bUnit; the page only binds to it.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IApiKeyGalaxyAckIdentityService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lists the keys that have an identity, as key id → <c>DOMAIN\user</c>. Decrypts only the user
|
||||||
|
/// secrets; never reads a password.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>The mapped identities.</returns>
|
||||||
|
Task<IReadOnlyDictionary<string, string>> ListAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Sets (or replaces) a key's Galaxy ack identity.</summary>
|
||||||
|
/// <param name="apiKeyId">The public key id.</param>
|
||||||
|
/// <param name="user">Galaxy user; required.</param>
|
||||||
|
/// <param name="domain">Optional domain.</param>
|
||||||
|
/// <param name="password">Password; may be empty (Galaxy accounts can have empty passwords).</param>
|
||||||
|
/// <param name="actor">The admin performing the change, for audit.</param>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task SetAsync(string apiKeyId, string user, string? domain, string? password, string actor, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Removes a key's Galaxy ack identity (no-op when none).</summary>
|
||||||
|
/// <param name="apiKeyId">The public key id.</param>
|
||||||
|
/// <param name="actor">The admin performing the change, for audit.</param>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task ClearAsync(string apiKeyId, string actor, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Secrets-store backed <see cref="IApiKeyGalaxyAckIdentityService"/>.</summary>
|
||||||
|
public sealed class ApiKeyGalaxyAckIdentityService : IApiKeyGalaxyAckIdentityService
|
||||||
|
{
|
||||||
|
private const string UserSuffix = "/galaxy-ack-user";
|
||||||
|
|
||||||
|
private readonly ISecretStore _store;
|
||||||
|
private readonly ISecretCipher _cipher;
|
||||||
|
private readonly IAuditWriter _audit;
|
||||||
|
private readonly ISecretCacheInvalidator? _invalidator;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="ApiKeyGalaxyAckIdentityService"/> class.</summary>
|
||||||
|
/// <param name="store">The secret store.</param>
|
||||||
|
/// <param name="cipher">The secret cipher.</param>
|
||||||
|
/// <param name="audit">The audit writer.</param>
|
||||||
|
/// <param name="invalidator">Optional resolver-cache invalidator so a change applies to the next ack.</param>
|
||||||
|
public ApiKeyGalaxyAckIdentityService(
|
||||||
|
ISecretStore store, ISecretCipher cipher, IAuditWriter audit, ISecretCacheInvalidator? invalidator = null)
|
||||||
|
{
|
||||||
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
|
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
|
||||||
|
_audit = audit ?? throw new ArgumentNullException(nameof(audit));
|
||||||
|
_invalidator = invalidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyDictionary<string, string>> ListAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var rows = await _store.ListAsync(includeDeleted: false, ct).ConfigureAwait(false);
|
||||||
|
foreach (var meta in rows)
|
||||||
|
{
|
||||||
|
var name = meta.Name.Value;
|
||||||
|
if (!name.StartsWith(GalaxyAckIdentitySecrets.Prefix, StringComparison.Ordinal)
|
||||||
|
|| !name.EndsWith(UserSuffix, StringComparison.Ordinal)) continue;
|
||||||
|
|
||||||
|
var keyId = name[GalaxyAckIdentitySecrets.Prefix.Length..^UserSuffix.Length];
|
||||||
|
if (keyId.Length == 0 || keyId.Contains('/')) continue;
|
||||||
|
|
||||||
|
var row = await _store.GetAsync(meta.Name, ct).ConfigureAwait(false);
|
||||||
|
if (row is null || row.IsDeleted) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result[keyId] = _cipher.Decrypt(row);
|
||||||
|
}
|
||||||
|
catch (SecretDecryptionException)
|
||||||
|
{
|
||||||
|
result[keyId] = "(unreadable)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SetAsync(
|
||||||
|
string apiKeyId, string user, string? domain, string? password, string actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
user = (user ?? string.Empty).Trim();
|
||||||
|
if (user.Length == 0) throw new ArgumentException("A Galaxy user is required.", nameof(user));
|
||||||
|
if (user.Contains('\\')) throw new ArgumentException("Enter the domain in its own field, not as DOMAIN\\user.", nameof(user));
|
||||||
|
if (domain is not null && domain.Contains('\\')) throw new ArgumentException("The domain must not contain '\\'.", nameof(domain));
|
||||||
|
|
||||||
|
var userName = new SecretName(GalaxyAckIdentitySecrets.UserSecretName(apiKeyId));
|
||||||
|
var passwordName = new SecretName(GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId));
|
||||||
|
|
||||||
|
// Password FIRST: the driver refuses a user without a password, so writing in this order means a
|
||||||
|
// concurrent ack never sees a half-written identity it would act on.
|
||||||
|
await UpsertAsync(passwordName, password ?? string.Empty, $"Galaxy ack password for API key {apiKeyId}", actor, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await UpsertAsync(userName, GalaxyAckIdentitySecrets.FormatIdentity(user, domain),
|
||||||
|
$"Galaxy ack user for API key {apiKeyId}", actor, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await WriteAuditAsync("apikey.galaxy-ack-identity.set", apiKeyId, actor, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ClearAsync(string apiKeyId, string actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userName = new SecretName(GalaxyAckIdentitySecrets.UserSecretName(apiKeyId));
|
||||||
|
var passwordName = new SecretName(GalaxyAckIdentitySecrets.PasswordSecretName(apiKeyId));
|
||||||
|
|
||||||
|
// User first, so the identity disappears before its password does.
|
||||||
|
var removedUser = await _store.DeleteAsync(userName, actor, ct).ConfigureAwait(false);
|
||||||
|
_invalidator?.Invalidate(userName);
|
||||||
|
var removedPassword = await _store.DeleteAsync(passwordName, actor, ct).ConfigureAwait(false);
|
||||||
|
_invalidator?.Invalidate(passwordName);
|
||||||
|
|
||||||
|
if (removedUser || removedPassword)
|
||||||
|
{
|
||||||
|
await WriteAuditAsync("apikey.galaxy-ack-identity.clear", apiKeyId, actor, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertAsync(SecretName name, string value, string description, string actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = _cipher.Encrypt(name, value, SecretContentType.Text) with
|
||||||
|
{
|
||||||
|
Description = description,
|
||||||
|
CreatedBy = actor,
|
||||||
|
UpdatedBy = actor,
|
||||||
|
};
|
||||||
|
await _store.UpsertAsync(row, ct).ConfigureAwait(false);
|
||||||
|
_invalidator?.Invalidate(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task WriteAuditAsync(string action, string apiKeyId, string actor, CancellationToken ct) =>
|
||||||
|
_audit.WriteAsync(
|
||||||
|
new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = actor,
|
||||||
|
Action = action,
|
||||||
|
Outcome = AuditOutcome.Success,
|
||||||
|
Category = "ApiKeys",
|
||||||
|
// The key id only — never the user's password, and not the account either.
|
||||||
|
Target = apiKeyId,
|
||||||
|
},
|
||||||
|
ct);
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck
|
||||||
@inject IApiKeyAdminService Keys
|
@inject IApiKeyAdminService Keys
|
||||||
|
@inject IApiKeyGalaxyAckIdentityService AckIdentities
|
||||||
|
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -50,6 +52,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (_ackEditKeyId is not null)
|
||||||
|
{
|
||||||
|
<section class="panel mb-3">
|
||||||
|
<div class="panel-head">Galaxy alarm-ack identity — key <span class="mono">@_ackEditKeyId</span></div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="form-text mb-2">
|
||||||
|
Native Galaxy alarm acknowledges from sessions using this key authenticate as this Galaxy account, and
|
||||||
|
the Historian records it as the operator. Stored encrypted in the Secrets store
|
||||||
|
(<span class="mono">otopcua/apikeys/@(_ackEditKeyId)/galaxy-ack-*</span>), never in the database. Keys
|
||||||
|
without an identity use the Galaxy driver's fallback account; with neither, acknowledges are refused.
|
||||||
|
On a split admin/driver topology the two secrets must also exist on the driver nodes.
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="ack-user">Galaxy user</label>
|
||||||
|
<input id="ack-user" class="form-control form-control-sm" @bind="_ackUser" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="ack-domain">Domain (optional)</label>
|
||||||
|
<input id="ack-domain" class="form-control form-control-sm" @bind="_ackDomain" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="ack-password">Password</label>
|
||||||
|
<input id="ack-password" type="password" class="form-control form-control-sm" @bind="_ackPassword"
|
||||||
|
autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveAckIdentityAsync" disabled="@_busy">Save</button>
|
||||||
|
@if (_ackIdentities.ContainsKey(_ackEditKeyId))
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="ClearAckIdentityAsync" disabled="@_busy">Remove</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-secondary" @onclick="CloseAckEditor" disabled="@_busy">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">Keys</div>
|
<div class="panel-head">Keys</div>
|
||||||
@if (_keys is null)
|
@if (_keys is null)
|
||||||
@@ -71,6 +112,7 @@
|
|||||||
<th>Principal</th>
|
<th>Principal</th>
|
||||||
<th>Key id</th>
|
<th>Key id</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Galaxy ack identity</th>
|
||||||
<th>Created (UTC)</th>
|
<th>Created (UTC)</th>
|
||||||
<th>Expires (UTC)</th>
|
<th>Expires (UTC)</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -97,6 +139,16 @@
|
|||||||
<span class="badge bg-success">active</span>
|
<span class="badge bg-success">active</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (_ackIdentities.TryGetValue(k.ApiKeyId, out var ackId))
|
||||||
|
{
|
||||||
|
<span class="mono">@ackId</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">driver fallback</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>@k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")</td>
|
<td>@k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")</td>
|
||||||
<td>@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—")</td>
|
<td>@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—")</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
@@ -105,6 +157,8 @@
|
|||||||
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
|
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
|
||||||
@(k.Enabled ? "Revoke" : "Re-enable")
|
@(k.Enabled ? "Revoke" : "Re-enable")
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => OpenAckEditor(k.ApiKeyId)" disabled="@_busy">Ack identity</button>
|
||||||
<button class="btn btn-sm btn-outline-danger"
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
@onclick="() => RequestDelete(k)" disabled="@_busy">Delete</button>
|
@onclick="() => RequestDelete(k)" disabled="@_busy">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +175,17 @@
|
|||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
|
||||||
|
|
||||||
private IReadOnlyList<ApiKey>? _keys;
|
private IReadOnlyList<ApiKey>? _keys;
|
||||||
|
private IReadOnlyDictionary<string, string> _ackIdentities = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
// Galaxy ack identity editor (lmxopcua#749). The password lives in component state only while the
|
||||||
|
// editor is open and is cleared on save/cancel; it is never rendered back.
|
||||||
|
private string? _ackEditKeyId;
|
||||||
|
private string _ackUser = "";
|
||||||
|
private string _ackDomain = "";
|
||||||
|
private string _ackPassword = "";
|
||||||
private string _newName = "";
|
private string _newName = "";
|
||||||
private string _newPrincipal = "";
|
private string _newPrincipal = "";
|
||||||
private DateTime? _newExpires;
|
private DateTime? _newExpires;
|
||||||
@@ -142,6 +206,8 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_keys = await Keys.ListAsync(default);
|
_keys = await Keys.ListAsync(default);
|
||||||
|
try { _ackIdentities = await AckIdentities.ListAsync(default); }
|
||||||
|
catch (Exception ex) { _ackIdentities = new Dictionary<string, string>(); _error = $"Galaxy ack identities unavailable: {ex.Message}"; }
|
||||||
// The one-time banner is only meaningful while its key exists; the credential is
|
// The one-time banner is only meaningful while its key exists; the credential is
|
||||||
// otua_<keyId>_<secret>, so drop it once no listed key matches (deleted here or elsewhere).
|
// otua_<keyId>_<secret>, so drop it once no listed key matches (deleted here or elsewhere).
|
||||||
if (_created is not null
|
if (_created is not null
|
||||||
@@ -222,9 +288,73 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Keys.DeleteAsync(apiKeyId, default);
|
await Keys.DeleteAsync(apiKeyId, default);
|
||||||
|
// A deleted key's ack identity must not linger in the Secrets store.
|
||||||
|
await AckIdentities.ClearAsync(apiKeyId, await ActorAsync(), default);
|
||||||
|
if (_ackEditKeyId == apiKeyId) CloseAckEditor();
|
||||||
await ReloadAsync();
|
await ReloadAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
finally { _busy = false; }
|
finally { _busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenAckEditor(string apiKeyId)
|
||||||
|
{
|
||||||
|
_ackEditKeyId = apiKeyId;
|
||||||
|
_ackPassword = "";
|
||||||
|
if (_ackIdentities.TryGetValue(apiKeyId, out var current))
|
||||||
|
{
|
||||||
|
var slash = current.IndexOf('\\');
|
||||||
|
_ackDomain = slash < 0 ? "" : current[..slash];
|
||||||
|
_ackUser = slash < 0 ? current : current[(slash + 1)..];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ackUser = "";
|
||||||
|
_ackDomain = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseAckEditor()
|
||||||
|
{
|
||||||
|
_ackEditKeyId = null;
|
||||||
|
_ackUser = "";
|
||||||
|
_ackDomain = "";
|
||||||
|
_ackPassword = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAckIdentityAsync()
|
||||||
|
{
|
||||||
|
if (_ackEditKeyId is not { } id) return;
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AckIdentities.SetAsync(id, _ackUser, _ackDomain, _ackPassword, await ActorAsync(), default);
|
||||||
|
CloseAckEditor();
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ClearAckIdentityAsync()
|
||||||
|
{
|
||||||
|
if (_ackEditKeyId is not { } id) return;
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AckIdentities.ClearAsync(id, await ActorAsync(), default);
|
||||||
|
CloseAckEditor();
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ActorAsync()
|
||||||
|
{
|
||||||
|
var user = AuthState is null ? null : (await AuthState).User;
|
||||||
|
return user?.Identity?.Name is { Length: > 0 } name ? name : "unknown";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -84,6 +84,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@* Native alarm acknowledge credential (lmxopcua#749) *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.09s">
|
||||||
|
<div class="panel-head">Alarm acknowledge — fallback Galaxy account</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="form-text mb-2">
|
||||||
|
The gateway acknowledges by authenticating as a Galaxy user and writing <code>AckMsg</code>, so the Historian
|
||||||
|
records that user. An API key with its own Galaxy ack identity (set on <a href="/apikeys">API keys</a>) uses
|
||||||
|
that; keys without one and anonymous sessions use this account. With neither, acknowledges are refused.
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Fallback user</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.AckFallbackUser" @bind-Value:after="EmitAsync" class="form-control form-control-sm"
|
||||||
|
placeholder="(none — refuse)" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Fallback domain (optional)</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.AckFallbackDomain" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Fallback password secret ref</label>
|
||||||
|
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
|
||||||
|
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@* Repository *@
|
@* Repository *@
|
||||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||||
<div class="panel-head">Galaxy repository</div>
|
<div class="panel-head">Galaxy repository</div>
|
||||||
@@ -237,12 +266,18 @@
|
|||||||
// GalaxyDriverOptions top-level
|
// GalaxyDriverOptions top-level
|
||||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
// GalaxyAlarmAckOptions (lmxopcua#749)
|
||||||
|
public string? AckFallbackUser { get; set; }
|
||||||
|
public string? AckFallbackDomain { get; set; }
|
||||||
|
public string? AckFallbackPasswordSecretRef { get; set; }
|
||||||
|
|
||||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||||
{
|
{
|
||||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||||
|
var ack = r.AlarmAck ?? new GalaxyAlarmAckOptions();
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
GatewayEndpoint = gw.Endpoint,
|
GatewayEndpoint = gw.Endpoint,
|
||||||
@@ -262,6 +297,9 @@
|
|||||||
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
||||||
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
||||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||||
|
AckFallbackUser = ack.FallbackUser,
|
||||||
|
AckFallbackDomain = ack.FallbackDomain,
|
||||||
|
AckFallbackPasswordSecretRef = ack.FallbackPasswordSecretRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +326,10 @@
|
|||||||
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
||||||
{
|
{
|
||||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||||
|
AlarmAck = new GalaxyAlarmAckOptions(
|
||||||
|
FallbackUser: string.IsNullOrWhiteSpace(AckFallbackUser) ? null : AckFallbackUser.Trim(),
|
||||||
|
FallbackDomain: string.IsNullOrWhiteSpace(AckFallbackDomain) ? null : AckFallbackDomain.Trim(),
|
||||||
|
FallbackPasswordSecretRef: string.IsNullOrWhiteSpace(AckFallbackPasswordSecretRef) ? null : AckFallbackPasswordSecretRef.Trim()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
|
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
|
||||||
// services above.
|
// services above.
|
||||||
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
|
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
|
||||||
|
// lmxopcua#749: per-API-key Galaxy alarm-ack identity, held in the Secrets store (never the DB).
|
||||||
|
services.AddScoped<ApiKeyAck.IApiKeyGalaxyAckIdentityService>(sp => new ApiKeyAck.ApiKeyGalaxyAckIdentityService(
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Secrets.Abstractions.ISecretStore>(),
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Secrets.Abstractions.ISecretCipher>(),
|
||||||
|
sp.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
|
||||||
|
sp.GetService<ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator>()));
|
||||||
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||||
services.AddScoped<RawTagCsvExportReader>();
|
services.AddScoped<RawTagCsvExportReader>();
|
||||||
// /hosts eviction: what the fleet is running (cached per deployment id), unioned with the authored set.
|
// /hosts eviction: what the fleet is running (cached per deployment id), unioned with the authored set.
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
|||||||
if (_actorRegistry.TryGet<DriverHostActorKey>(out var driverHost))
|
if (_actorRegistry.TryGet<DriverHostActorKey>(out var driverHost))
|
||||||
{
|
{
|
||||||
driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck(
|
driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck(
|
||||||
nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser));
|
nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser, nativeAck.ApiKeyId));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|||||||
/// used to resolve the ack back to its backing driver.</param>
|
/// used to resolve the ack back to its backing driver.</param>
|
||||||
/// <param name="Comment">The operator's acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
/// <param name="Comment">The operator's acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
||||||
/// <param name="OperatorUser">The authenticated operator's display name (empty when none resolves).</param>
|
/// <param name="OperatorUser">The authenticated operator's display name (empty when none resolves).</param>
|
||||||
public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
|
/// <param name="ApiKeyId">Public id of the API key the session authenticated with; null for anonymous. Not a
|
||||||
|
/// secret — the Galaxy driver uses it to find the key's Galaxy ack identity (lmxopcua#749).</param>
|
||||||
|
public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser, string? ApiKeyId = null);
|
||||||
|
|||||||
@@ -2149,7 +2149,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
|
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
|
||||||
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
||||||
Comment: comment?.Text,
|
Comment: comment?.Text,
|
||||||
OperatorUser: AlarmOperatorName(identity!)));
|
OperatorUser: AlarmOperatorName(identity!),
|
||||||
|
ApiKeyId: identity!.ApiKeyId));
|
||||||
|
|
||||||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||||||
// condition event (the SDK reports none of its own).
|
// condition event (the SDK reports none of its own).
|
||||||
|
|||||||
@@ -605,7 +605,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
/// <param name="ConditionNodeId">The folder-scoped condition NodeId the operator acknowledged.</param>
|
/// <param name="ConditionNodeId">The folder-scoped condition NodeId the operator acknowledged.</param>
|
||||||
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system; null when none.</param>
|
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system; null when none.</param>
|
||||||
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
|
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
|
||||||
public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser);
|
/// <param name="ApiKeyId">Public id of the API key the session authenticated with; null for anonymous. Not a
|
||||||
|
/// secret — the Galaxy driver uses it to find the key's Galaxy ack identity (lmxopcua#749).</param>
|
||||||
|
public sealed record RouteNativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser, string? ApiKeyId = null);
|
||||||
|
|
||||||
/// <summary>Self-sent by the 03/S15 audit timer, <see cref="_commsRecoveryAuditDelay"/> after a driver
|
/// <summary>Self-sent by the 03/S15 audit timer, <see cref="_commsRecoveryAuditDelay"/> after a driver
|
||||||
/// reconnected: report any of its refs still latched <c>BadNoCommunication</c>. See
|
/// reconnected: report any of its refs still latched <c>BadNoCommunication</c>. See
|
||||||
@@ -2320,7 +2322,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
// Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the
|
// Fire-and-forget: the OPC UA Part 9 ack already committed the local condition state, and the
|
||||||
// driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The
|
// driver's AcknowledgeAsync surfaces no per-condition status, so there is nothing to reply. The
|
||||||
// driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on).
|
// driver correlates on ConditionId (= the authored alarm FullName the inverse map keyed on).
|
||||||
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser));
|
entry.Actor.Tell(new DriverInstanceActor.RouteAlarmAck(target.RawPath, msg.Comment, msg.OperatorUser, msg.ApiKeyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
|
/// <summary>Whether this node should service a Primary-only data-plane operation right now — the single
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// (= the equipment tag's <c>FullName</c>/AlarmFullReference).</param>
|
/// (= the equipment tag's <c>FullName</c>/AlarmFullReference).</param>
|
||||||
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system; null when none.</param>
|
/// <param name="Comment">Operator-supplied comment forwarded to the upstream alarm system; null when none.</param>
|
||||||
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
|
/// <param name="OperatorUser">The authenticated principal performing the acknowledge.</param>
|
||||||
public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser);
|
/// <param name="ApiKeyId">Public id of the API key the session authenticated with; null for anonymous. Not a
|
||||||
|
/// secret — the Galaxy driver uses it to find the key's Galaxy ack identity (lmxopcua#749).</param>
|
||||||
|
public sealed record RouteAlarmAck(string ConditionId, string? Comment, string OperatorUser, string? ApiKeyId = null);
|
||||||
public sealed record Subscribe(IReadOnlyList<string> FullReferences, TimeSpan PublishingInterval);
|
public sealed record Subscribe(IReadOnlyList<string> FullReferences, TimeSpan PublishingInterval);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the set of references this driver should keep subscribed for the lifetime of the
|
/// Sets the set of references this driver should keep subscribed for the lifetime of the
|
||||||
@@ -1266,7 +1268,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
SourceNodeId: msg.ConditionId,
|
SourceNodeId: msg.ConditionId,
|
||||||
ConditionId: msg.ConditionId,
|
ConditionId: msg.ConditionId,
|
||||||
Comment: msg.Comment,
|
Comment: msg.Comment,
|
||||||
OperatorUser: msg.OperatorUser),
|
OperatorUser: msg.OperatorUser,
|
||||||
|
ApiKeyId: msg.ApiKeyId),
|
||||||
};
|
};
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
try
|
try
|
||||||
@@ -1280,6 +1283,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
_log.Info("DriverInstance {Id}: acknowledged native condition {Cond} by {User}",
|
_log.Info("DriverInstance {Id}: acknowledged native condition {Cond} by {User}",
|
||||||
_driverInstanceId, msg.ConditionId, msg.OperatorUser);
|
_driverInstanceId, msg.ConditionId, msg.OperatorUser);
|
||||||
}
|
}
|
||||||
|
catch (DriverOperationRefusedException ex)
|
||||||
|
{
|
||||||
|
// A deliberate refusal (e.g. no Galaxy ack credential for this session, lmxopcua#749) — a
|
||||||
|
// configuration verdict, not a backend fault, so no stack trace; the message names the fix.
|
||||||
|
_log.Warning("DriverInstance {Id}: native-alarm acknowledge of {Cond} by {User} refused — {Reason}",
|
||||||
|
_driverInstanceId, msg.ConditionId, msg.OperatorUser, ex.Message);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.Warning(ex, "DriverInstance {Id}: native-alarm acknowledge of {Cond} failed",
|
_log.Warning(ex, "DriverInstance {Id}: native-alarm acknowledge of {Cond} failed",
|
||||||
|
|||||||
+33
@@ -171,6 +171,39 @@ public sealed class DriverResiliencePipelineBuilderTests
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lmxopcua#749: a deliberate driver refusal (e.g. no Galaxy ack credential) is a configuration verdict,
|
||||||
|
/// not a backend fault — it must not count toward the breaker, or a few misconfigured acks would block
|
||||||
|
/// every correctly-configured caller on the host. Positive control: the same number of ordinary
|
||||||
|
/// failures DOES open it (<see cref="CircuitBreaker_Opens_AfterFailureThreshold_OnTierA"/>).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverOperationRefused_DoesNotOpenBreaker()
|
||||||
|
{
|
||||||
|
var builder = new DriverResiliencePipelineBuilder();
|
||||||
|
var pipeline = builder.GetOrCreate("drv-test", "host-r", DriverCapability.AlarmAcknowledge, TierAOptions);
|
||||||
|
|
||||||
|
var threshold = TierAOptions.Resolve(DriverCapability.AlarmAcknowledge).BreakerFailureThreshold;
|
||||||
|
threshold.ShouldBeGreaterThan(0);
|
||||||
|
for (var i = 0; i < threshold * 2; i++)
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<DriverOperationRefusedException>(async () =>
|
||||||
|
await pipeline.ExecuteAsync(async _ =>
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
throw new DriverOperationRefusedException("no credential");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
var ran = false;
|
||||||
|
await pipeline.ExecuteAsync(async _ =>
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
ran = true;
|
||||||
|
});
|
||||||
|
ran.ShouldBeTrue("the breaker must stay closed after refusals");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that timeout cancels slow operations.</summary>
|
/// <summary>Verifies that timeout cancels slow operations.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Timeout_Cancels_SlowOperation()
|
public async Task Timeout_Cancels_SlowOperation()
|
||||||
|
|||||||
+15
-12
@@ -191,8 +191,9 @@ public sealed class GalaxyDriverAlarmSourceTests
|
|||||||
|
|
||||||
ack.Calls.Count.ShouldBe(2);
|
ack.Calls.Count.ShouldBe(2);
|
||||||
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
||||||
ack.Calls[0].Comment.ShouldBe("shift handover");
|
ack.Calls[0].Comment.ShouldBe("shift handover [OPC UA: anonymous]");
|
||||||
ack.Calls[0].Operator.ShouldBe(string.Empty); // null OperatorUser must fall back to empty string
|
// lmxopcua#749: the gateway is sent the resolved Galaxy account, never an empty operator.
|
||||||
|
ack.Calls[0].Operator.ShouldBe("ack-op");
|
||||||
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
|
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,23 +213,24 @@ public sealed class GalaxyDriverAlarmSourceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that AcknowledgeAsync forwards the request's authenticated principal
|
/// Verifies that the request's OPC UA principal + key id (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>,
|
||||||
/// (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>) to the acknowledger so the
|
/// <see cref="AlarmAcknowledgeRequest.ApiKeyId"/>) are recorded in the ack comment, and the key id is what
|
||||||
/// gateway records WHO acked, rather than the generic empty-string fallback.
|
/// the credential resolver is asked about — the gateway operator is the resolved Galaxy account (#749).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAsync_forwards_OperatorUser()
|
public async Task AcknowledgeAsync_records_OpcUa_principal_in_comment()
|
||||||
{
|
{
|
||||||
var feed = new FakeAlarmFeed();
|
var feed = new FakeAlarmFeed();
|
||||||
var ack = new RecordingAcknowledger();
|
var ack = new RecordingAcknowledger();
|
||||||
using var driver = NewDriver(feed, ack);
|
using var driver = NewDriver(feed, ack);
|
||||||
|
|
||||||
await driver.AcknowledgeAsync(
|
await driver.AcknowledgeAsync(
|
||||||
[new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice")],
|
[new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice", ApiKeyId: "0a1b2c")],
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
ack.Calls.ShouldHaveSingleItem();
|
ack.Calls.ShouldHaveSingleItem();
|
||||||
ack.Calls[0].Operator.ShouldBe("alice");
|
ack.Calls[0].Operator.ShouldBe("ack-op");
|
||||||
|
ack.Calls[0].Comment.ShouldBe("cmt [OPC UA: alice, key 0a1b2c]");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
|
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
|
||||||
@@ -257,7 +259,8 @@ public sealed class GalaxyDriverAlarmSourceTests
|
|||||||
options: options,
|
options: options,
|
||||||
hierarchySource: null,
|
hierarchySource: null,
|
||||||
alarmAcknowledger: alarmAcknowledger,
|
alarmAcknowledger: alarmAcknowledger,
|
||||||
alarmFeed: alarmFeed);
|
alarmFeed: alarmFeed,
|
||||||
|
ackCredentialResolver: ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime.FixedAckCredentialResolver.Fallback());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GalaxyAlarmTransition NewTransition(
|
private static GalaxyAlarmTransition NewTransition(
|
||||||
@@ -311,12 +314,12 @@ public sealed class GalaxyDriverAlarmSourceTests
|
|||||||
/// <summary>Records an acknowledge call.</summary>
|
/// <summary>Records an acknowledge call.</summary>
|
||||||
/// <param name="alarmFullReference">The alarm full reference.</param>
|
/// <param name="alarmFullReference">The alarm full reference.</param>
|
||||||
/// <param name="comment">The acknowledgment comment.</param>
|
/// <param name="comment">The acknowledgment comment.</param>
|
||||||
/// <param name="operatorUser">The operator user.</param>
|
/// <param name="credential">The resolved Galaxy credential.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A completed task.</returns>
|
/// <returns>A completed task.</returns>
|
||||||
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
|
public Task AcknowledgeAsync(string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Calls.Add((alarmFullReference, comment, operatorUser));
|
Calls.Add((alarmFullReference, comment, credential.User));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,27 @@ public sealed class GalaxyDriverFactoryTests
|
|||||||
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
|
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>lmxopcua#749: the AlarmAck fallback section binds; absent means no fallback.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_BindsAlarmAckFallback()
|
||||||
|
{
|
||||||
|
GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig).Options.AlarmAck.HasFallback.ShouldBeFalse();
|
||||||
|
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
|
||||||
|
"MxAccess": { "ClientName": "OtOpcUa-A" },
|
||||||
|
"alarmAck": { "fallbackUser": "ack-svc", "fallbackDomain": "PLANT", "fallbackPasswordSecretRef": "secret:otopcua/galaxy/ack" }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var ack = GalaxyDriverFactoryExtensions.CreateInstance("g", json).Options.AlarmAck;
|
||||||
|
|
||||||
|
ack.HasFallback.ShouldBeTrue();
|
||||||
|
ack.FallbackUser.ShouldBe("ack-svc");
|
||||||
|
ack.FallbackDomain.ShouldBe("PLANT");
|
||||||
|
ack.FallbackPasswordSecretRef.ShouldBe("secret:otopcua/galaxy/ack");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that defaults are overridden from full config.</summary>
|
/// <summary>Verifies that defaults are overridden from full config.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateInstance_OverridesDefaults_FromFullConfig()
|
public void CreateInstance_OverridesDefaults_FromFullConfig()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||||
|
|
||||||
|
/// <summary>Logger that records every formatted message (and exception text) for leak assertions.</summary>
|
||||||
|
internal sealed class CapturingLogger : ILogger
|
||||||
|
{
|
||||||
|
/// <summary>Gets the formatted entries, each including the exception's ToString when present.</summary>
|
||||||
|
public List<(LogLevel Level, string Text)> Entries { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Gets every entry's text joined, for a single contains-check.</summary>
|
||||||
|
public string AllText => string.Join("\n", Entries.Select(e => e.Text));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
// Include the structured state values too — a password passed as a template argument would
|
||||||
|
// reach a structured sink even if the template text omitted it.
|
||||||
|
var values = state is IEnumerable<KeyValuePair<string, object?>> kvs
|
||||||
|
? string.Join(";", kvs.Select(kv => $"{kv.Key}={kv.Value}"))
|
||||||
|
: string.Empty;
|
||||||
|
Entries.Add((logLevel, $"{formatter(state, exception)} | {values} | {exception}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+417
@@ -0,0 +1,417 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lmxopcua#749 — operator-authenticated Galaxy ack (mxaccessgw 488649c+): the request carries a real
|
||||||
|
/// Galaxy account + password resolved from the API key's identity or the driver's fallback account,
|
||||||
|
/// refuses with neither, and never lets the password reach a log line or exception text.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyAckCredentialTests
|
||||||
|
{
|
||||||
|
private const string KeyId = "0a1b2c3d4e5f6071";
|
||||||
|
private const string KeyPassword = "key-pw-S3cr3t!";
|
||||||
|
private const string FallbackPassword = "fallback-pw-S3cr3t!";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ request mapping
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRequest_carries_user_domain_and_password()
|
||||||
|
{
|
||||||
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||||
|
|
||||||
|
var request = GatewayGalaxyAlarmAcknowledger.BuildRequest("Tank01.Level.HiHi", "investigating", credential);
|
||||||
|
|
||||||
|
request.AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
||||||
|
request.Comment.ShouldBe("investigating");
|
||||||
|
request.OperatorUser.ShouldBe("alice");
|
||||||
|
request.OperatorDomain.ShouldBe("CORP");
|
||||||
|
request.OperatorPassword.ShouldBe(KeyPassword);
|
||||||
|
request.ClientCorrelationId.ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildRequest_allows_empty_password_but_never_empty_user()
|
||||||
|
{
|
||||||
|
// Galaxy accounts can have empty passwords (gateway contract) — the field is sent empty.
|
||||||
|
var request = GatewayGalaxyAlarmAcknowledger.BuildRequest(
|
||||||
|
"A.B", "c", new GalaxyAckCredential("bob", null, null, GalaxyAckCredentialSource.Fallback));
|
||||||
|
request.OperatorPassword.ShouldBe(string.Empty);
|
||||||
|
request.OperatorDomain.ShouldBe(string.Empty);
|
||||||
|
request.OperatorUser.ShouldBe("bob");
|
||||||
|
|
||||||
|
// The credential type itself cannot be built with an empty user — the gateway rejects one.
|
||||||
|
Should.Throw<ArgumentException>(() => new GalaxyAckCredential(" ", "D", "p", GalaxyAckCredentialSource.Fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Credential_and_resolution_ToString_omit_the_password()
|
||||||
|
{
|
||||||
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||||
|
credential.ToString().ShouldNotContain(KeyPassword);
|
||||||
|
credential.ToString().ShouldContain("CORP\\alice");
|
||||||
|
GalaxyAckCredentialResolution.Resolved(credential).ToString().ShouldNotContain(KeyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ reply handling / redaction
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EvaluateReply_protocol_failure_throws_with_password_scrubbed()
|
||||||
|
{
|
||||||
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||||
|
var reply = new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.InvalidRequest,
|
||||||
|
// Pathological gateway echo — the scrub is defence in depth over the gateway's contract.
|
||||||
|
Message = $"auth failed for alice/{KeyPassword}",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
|
||||||
|
var ex = Should.Throw<InvalidOperationException>(
|
||||||
|
() => GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger));
|
||||||
|
|
||||||
|
ex.ToString().ShouldNotContain(KeyPassword);
|
||||||
|
ex.Message.ShouldContain(GatewayGalaxyAlarmAcknowledger.Redacted);
|
||||||
|
ex.Message.ShouldContain("CORP\\alice");
|
||||||
|
logger.AllText.ShouldNotContain(KeyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EvaluateReply_native_failure_warns_without_password()
|
||||||
|
{
|
||||||
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||||
|
var reply = new AcknowledgeAlarmReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Hresult = unchecked((int)0x80070005),
|
||||||
|
};
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
|
||||||
|
GatewayGalaxyAlarmAcknowledger.EvaluateReply(reply, "A.B", credential, logger);
|
||||||
|
|
||||||
|
logger.Entries.ShouldHaveSingleItem().Level.ShouldBe(LogLevel.Warning);
|
||||||
|
logger.AllText.ShouldContain("CORP\\alice");
|
||||||
|
logger.AllText.ShouldNotContain(KeyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledger_transport_failure_exception_text_has_no_password()
|
||||||
|
{
|
||||||
|
var client = MxGatewayClient.Create(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://127.0.0.1:1", UriKind.Absolute),
|
||||||
|
ApiKey = "test-key",
|
||||||
|
UseTls = false,
|
||||||
|
ConnectTimeout = TimeSpan.FromMilliseconds(200),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromMilliseconds(200),
|
||||||
|
});
|
||||||
|
await using (client)
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var acknowledger = new GatewayGalaxyAlarmAcknowledger(client, logger);
|
||||||
|
var credential = new GalaxyAckCredential("alice", "CORP", KeyPassword, GalaxyAckCredentialSource.ApiKey);
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(
|
||||||
|
() => acknowledger.AcknowledgeAsync("A.B", "c", credential, CancellationToken.None));
|
||||||
|
|
||||||
|
ex.ShouldNotBeNull();
|
||||||
|
ex.ToString().ShouldNotContain(KeyPassword);
|
||||||
|
logger.AllText.ShouldNotContain(KeyPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scrub_replaces_every_occurrence_and_ignores_empty_secret()
|
||||||
|
{
|
||||||
|
GatewayGalaxyAlarmAcknowledger.Scrub("a pw b pw", "pw").ShouldBe("a *** b ***");
|
||||||
|
GatewayGalaxyAlarmAcknowledger.Scrub("unchanged", string.Empty).ShouldBe("unchanged");
|
||||||
|
GatewayGalaxyAlarmAcknowledger.Scrub(null, "pw").ShouldBe(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ resolver paths
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_key_mapped_identity_wins_over_fallback()
|
||||||
|
{
|
||||||
|
var secrets = new DictSecretResolver
|
||||||
|
{
|
||||||
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
|
||||||
|
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
|
||||||
|
["otopcua/galaxy/ack-fallback"] = FallbackPassword,
|
||||||
|
};
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, logger);
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
|
||||||
|
|
||||||
|
var credential = result.Credential.ShouldNotBeNull();
|
||||||
|
credential.Source.ShouldBe(GalaxyAckCredentialSource.ApiKey);
|
||||||
|
credential.User.ShouldBe("alice");
|
||||||
|
credential.Domain.ShouldBe("CORP");
|
||||||
|
credential.Password.ShouldBe(KeyPassword);
|
||||||
|
logger.AllText.ShouldNotContain(KeyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_key_without_identity_uses_fallback()
|
||||||
|
{
|
||||||
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
||||||
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
|
||||||
|
|
||||||
|
var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
|
||||||
|
|
||||||
|
credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
|
||||||
|
credential.User.ShouldBe("ack-svc");
|
||||||
|
credential.Domain.ShouldBe("PLANT");
|
||||||
|
credential.Password.ShouldBe(FallbackPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_anonymous_uses_fallback()
|
||||||
|
{
|
||||||
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
||||||
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(apiKeyId: null, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Credential.ShouldNotBeNull().Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData(KeyId)]
|
||||||
|
public async Task Resolver_refuses_with_no_identity_and_no_fallback(string? apiKeyId)
|
||||||
|
{
|
||||||
|
var resolver = new GalaxyAckCredentialResolver(
|
||||||
|
"drv-x", new GalaxyAlarmAckOptions(), new DictSecretResolver(), new CapturingLogger());
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(apiKeyId, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Credential.ShouldBeNull();
|
||||||
|
result.RefusalReason.ShouldNotBeNull();
|
||||||
|
result.RefusalReason.ShouldContain("drv-x");
|
||||||
|
result.RefusalReason.ShouldContain("FallbackUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_refuses_key_with_user_but_no_password_even_when_fallback_exists()
|
||||||
|
{
|
||||||
|
// A key the admin explicitly mapped must not silently ack as the shared fallback account.
|
||||||
|
var secrets = new DictSecretResolver
|
||||||
|
{
|
||||||
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
|
||||||
|
["otopcua/galaxy/ack-fallback"] = FallbackPassword,
|
||||||
|
};
|
||||||
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), secrets, new CapturingLogger());
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Credential.ShouldBeNull();
|
||||||
|
result.RefusalReason.ShouldNotBeNull().ShouldContain("no password secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_key_with_empty_password_is_allowed()
|
||||||
|
{
|
||||||
|
var secrets = new DictSecretResolver
|
||||||
|
{
|
||||||
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "alice",
|
||||||
|
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = string.Empty,
|
||||||
|
};
|
||||||
|
var resolver = new GalaxyAckCredentialResolver("drv", new GalaxyAlarmAckOptions(), secrets, new CapturingLogger());
|
||||||
|
|
||||||
|
var credential = (await resolver.ResolveAsync(KeyId, CancellationToken.None)).Credential.ShouldNotBeNull();
|
||||||
|
credential.Password.ShouldBe(string.Empty);
|
||||||
|
credential.Domain.ShouldBe(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_refuses_literal_fallback_password_without_echoing_it()
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var resolver = new GalaxyAckCredentialResolver(
|
||||||
|
"drv", new GalaxyAlarmAckOptions("ack-svc", null, FallbackPassword), new DictSecretResolver(), logger);
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Credential.ShouldBeNull();
|
||||||
|
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(FallbackPassword);
|
||||||
|
logger.AllText.ShouldNotContain(FallbackPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_refuses_when_fallback_secret_absent()
|
||||||
|
{
|
||||||
|
var resolver = new GalaxyAckCredentialResolver("drv", WithFallback(), new DictSecretResolver(), new CapturingLogger());
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Credential.ShouldBeNull();
|
||||||
|
result.RefusalReason.ShouldNotBeNull().ShouldContain("secret:otopcua/galaxy/ack-fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("${secret:otopcua/galaxy/ack-fallback}")]
|
||||||
|
[InlineData("secret:otopcua/galaxy/ack-fallback")]
|
||||||
|
public async Task Resolver_accepts_both_secret_reference_spellings(string reference)
|
||||||
|
{
|
||||||
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
||||||
|
var resolver = new GalaxyAckCredentialResolver(
|
||||||
|
"drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, new CapturingLogger());
|
||||||
|
|
||||||
|
(await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull()
|
||||||
|
.Password.ShouldBe(FallbackPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw()
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var resolver = new GalaxyAckCredentialResolver(
|
||||||
|
"drv", WithFallback(), new ThrowingSecretResolver(KeyPassword), logger);
|
||||||
|
|
||||||
|
var result = await resolver.ResolveAsync(KeyId, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Credential.ShouldBeNull();
|
||||||
|
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(KeyPassword);
|
||||||
|
logger.AllText.ShouldNotContain(KeyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Identity_secret_names_and_parsing()
|
||||||
|
{
|
||||||
|
GalaxyAckIdentitySecrets.UserSecretName("0A1B").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-user");
|
||||||
|
GalaxyAckIdentitySecrets.PasswordSecretName("0a1b").ShouldBe("otopcua/apikeys/0a1b/galaxy-ack-password");
|
||||||
|
Should.Throw<ArgumentException>(() => GalaxyAckIdentitySecrets.UserSecretName("../x"));
|
||||||
|
GalaxyAckIdentitySecrets.ParseIdentity("CORP\\alice").ShouldBe(("alice", "CORP"));
|
||||||
|
GalaxyAckIdentitySecrets.ParseIdentity("alice").ShouldBe(("alice", string.Empty));
|
||||||
|
GalaxyAckIdentitySecrets.FormatIdentity("alice", "CORP").ShouldBe("CORP\\alice");
|
||||||
|
GalaxyAckIdentitySecrets.FormatIdentity("alice", " ").ShouldBe("alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ driver paths
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_key_mapped_path_sends_the_keys_account()
|
||||||
|
{
|
||||||
|
var ack = new CredentialRecordingAcknowledger();
|
||||||
|
var secrets = new DictSecretResolver
|
||||||
|
{
|
||||||
|
[GalaxyAckIdentitySecrets.UserSecretName(KeyId)] = "CORP\\alice",
|
||||||
|
[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)] = KeyPassword,
|
||||||
|
};
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
using var driver = NewDriver(Opts(), ack, secrets, logger);
|
||||||
|
|
||||||
|
await driver.AcknowledgeAsync(
|
||||||
|
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None);
|
||||||
|
|
||||||
|
var call = ack.Calls.ShouldHaveSingleItem();
|
||||||
|
call.Credential.User.ShouldBe("alice");
|
||||||
|
call.Credential.Password.ShouldBe(KeyPassword);
|
||||||
|
call.Comment.ShouldBe($"cmt [OPC UA: mes-line1, key {KeyId}]");
|
||||||
|
logger.AllText.ShouldNotContain(KeyPassword);
|
||||||
|
logger.AllText.ShouldContain("mes-line1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_fallback_path_sends_the_fallback_account()
|
||||||
|
{
|
||||||
|
var ack = new CredentialRecordingAcknowledger();
|
||||||
|
var secrets = new DictSecretResolver { ["otopcua/galaxy/ack-fallback"] = FallbackPassword };
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
using var driver = NewDriver(Opts() with { AlarmAck = WithFallback() }, ack, secrets, logger);
|
||||||
|
|
||||||
|
await driver.AcknowledgeAsync(
|
||||||
|
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", null, OperatorUser: "anonymous")], CancellationToken.None);
|
||||||
|
|
||||||
|
var call = ack.Calls.ShouldHaveSingleItem();
|
||||||
|
call.Credential.Source.ShouldBe(GalaxyAckCredentialSource.Fallback);
|
||||||
|
call.Credential.User.ShouldBe("ack-svc");
|
||||||
|
call.Credential.Password.ShouldBe(FallbackPassword);
|
||||||
|
call.Comment.ShouldBe("[OPC UA: anonymous]");
|
||||||
|
logger.AllText.ShouldNotContain(FallbackPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_refused_path_sends_nothing_and_throws_refusal()
|
||||||
|
{
|
||||||
|
var ack = new CredentialRecordingAcknowledger();
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
using var driver = NewDriver(Opts(), ack, new DictSecretResolver(), logger);
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<DriverOperationRefusedException>(() => driver.AcknowledgeAsync(
|
||||||
|
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", "cmt", "mes-line1", KeyId)], CancellationToken.None));
|
||||||
|
|
||||||
|
ack.Calls.ShouldBeEmpty(); // never reaches the gateway — and so never with an empty operator_user
|
||||||
|
ex.Message.ShouldContain("Tank01.Level.HiHi");
|
||||||
|
logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Text.Contains("refused"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Annotate_comment_forms()
|
||||||
|
{
|
||||||
|
GalaxyDriver.AnnotateAckComment("x", "p", "k").ShouldBe("x [OPC UA: p, key k]");
|
||||||
|
GalaxyDriver.AnnotateAckComment(" ", null, null).ShouldBe("[OPC UA: anonymous]");
|
||||||
|
GalaxyDriver.AnnotateAckComment(null, "p", null).ShouldBe("[OPC UA: p]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
private static GalaxyAlarmAckOptions WithFallback() =>
|
||||||
|
new("ack-svc", "PLANT", "secret:otopcua/galaxy/ack-fallback");
|
||||||
|
|
||||||
|
private static GalaxyDriverOptions Opts() => new(
|
||||||
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "dev:key"),
|
||||||
|
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
||||||
|
new GalaxyRepositoryOptions(),
|
||||||
|
new GalaxyReconnectOptions());
|
||||||
|
|
||||||
|
private static GalaxyDriver NewDriver(
|
||||||
|
GalaxyDriverOptions options, IGalaxyAlarmAcknowledger ack, ISecretResolver secrets, ILogger logger) =>
|
||||||
|
new("drv", options, hierarchySource: null, alarmAcknowledger: ack,
|
||||||
|
logger: new TypedLogger<GalaxyDriver>(logger), secretResolver: secrets);
|
||||||
|
|
||||||
|
private sealed class TypedLogger<T>(ILogger inner) : ILogger<T>
|
||||||
|
{
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => inner.BeginScope(state);
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => inner.IsEnabled(logLevel);
|
||||||
|
public void Log<TState>(LogLevel l, EventId id, TState s, Exception? e, Func<TState, Exception?, string> f)
|
||||||
|
=> inner.Log(l, id, s, e, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CredentialRecordingAcknowledger : IGalaxyAlarmAcknowledger
|
||||||
|
{
|
||||||
|
public List<(string AlarmRef, string Comment, GalaxyAckCredential Credential)> Calls { get; } = [];
|
||||||
|
|
||||||
|
public Task AcknowledgeAsync(
|
||||||
|
string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Calls.Add((alarmFullReference, comment, credential));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DictSecretResolver : Dictionary<string, string>, ISecretResolver
|
||||||
|
{
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult(TryGetValue(name.Value, out var v) ? v : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingSecretResolver(string leak) : ISecretResolver
|
||||||
|
{
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
throw new InvalidOperationException($"store exploded near {leak}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -124,7 +124,8 @@ public sealed class GalaxyDriverRawPathKeyingTests
|
|||||||
var feed = new FakeAlarmFeed();
|
var feed = new FakeAlarmFeed();
|
||||||
var ack = new RecordingAcknowledger();
|
var ack = new RecordingAcknowledger();
|
||||||
using var driver = new GalaxyDriver(
|
using var driver = new GalaxyDriver(
|
||||||
"g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed);
|
"g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed,
|
||||||
|
ackCredentialResolver: FixedAckCredentialResolver.Fallback());
|
||||||
|
|
||||||
var handle = await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None);
|
var handle = await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None);
|
||||||
var observed = new List<AlarmEventArgs>();
|
var observed = new List<AlarmEventArgs>();
|
||||||
@@ -251,9 +252,9 @@ public sealed class GalaxyDriverRawPathKeyingTests
|
|||||||
{
|
{
|
||||||
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
||||||
|
|
||||||
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
|
public Task AcknowledgeAsync(string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Calls.Add((alarmFullReference, comment, operatorUser));
|
Calls.Add((alarmFullReference, comment, credential.User));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-10
@@ -45,10 +45,10 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
public Task AcknowledgeAsync(
|
public Task AcknowledgeAsync(
|
||||||
string alarmFullReference,
|
string alarmFullReference,
|
||||||
string comment,
|
string comment,
|
||||||
string operatorUser,
|
GalaxyAckCredential credential,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Calls.Add((alarmFullReference, comment, operatorUser));
|
Calls.Add((alarmFullReference, comment, credential.User));
|
||||||
return _outcomes.Count > 0 ? _outcomes.Dequeue()() : Task.CompletedTask;
|
return _outcomes.Count > 0 ? _outcomes.Dequeue()() : Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,9 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
new GalaxyRepositoryOptions(),
|
new GalaxyRepositoryOptions(),
|
||||||
new GalaxyReconnectOptions());
|
new GalaxyReconnectOptions());
|
||||||
|
|
||||||
|
private static readonly GalaxyAckCredential Alice =
|
||||||
|
new("alice", "CORP", "alice-pw", GalaxyAckCredentialSource.ApiKey);
|
||||||
|
|
||||||
private static AlarmAcknowledgeRequest Ack(string conditionId, string comment = "c", string source = "")
|
private static AlarmAcknowledgeRequest Ack(string conditionId, string comment = "c", string source = "")
|
||||||
=> new(SourceNodeId: source, ConditionId: conditionId, Comment: comment);
|
=> new(SourceNodeId: source, ConditionId: conditionId, Comment: comment);
|
||||||
|
|
||||||
@@ -79,7 +82,8 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
fake.Enqueue(() => Task.CompletedTask);
|
fake.Enqueue(() => Task.CompletedTask);
|
||||||
|
|
||||||
var driver = new GalaxyDriver("g", Opts(),
|
var driver = new GalaxyDriver("g", Opts(),
|
||||||
hierarchySource: null, alarmAcknowledger: fake);
|
hierarchySource: null, alarmAcknowledger: fake,
|
||||||
|
ackCredentialResolver: FixedAckCredentialResolver.Fallback());
|
||||||
|
|
||||||
await Should.ThrowAsync<IOException>(
|
await Should.ThrowAsync<IOException>(
|
||||||
() => driver.AcknowledgeAsync(
|
() => driver.AcknowledgeAsync(
|
||||||
@@ -93,8 +97,8 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
|
|
||||||
fake.Calls.Count.ShouldBe(2);
|
fake.Calls.Count.ShouldBe(2);
|
||||||
fake.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
fake.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
||||||
fake.Calls[0].Comment.ShouldBe("investigating");
|
fake.Calls[0].Comment.ShouldBe("investigating [OPC UA: anonymous]");
|
||||||
fake.Calls[1].Comment.ShouldBe("resolved");
|
fake.Calls[1].Comment.ShouldBe("resolved [OPC UA: anonymous]");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -109,7 +113,8 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
fake.Enqueue(() => Task.CompletedTask);
|
fake.Enqueue(() => Task.CompletedTask);
|
||||||
|
|
||||||
var driver = new GalaxyDriver("g", Opts(),
|
var driver = new GalaxyDriver("g", Opts(),
|
||||||
hierarchySource: null, alarmAcknowledger: fake);
|
hierarchySource: null, alarmAcknowledger: fake,
|
||||||
|
ackCredentialResolver: FixedAckCredentialResolver.Fallback());
|
||||||
|
|
||||||
await Should.ThrowAsync<InvalidOperationException>(
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
() => driver.AcknowledgeAsync(
|
() => driver.AcknowledgeAsync(
|
||||||
@@ -131,14 +136,15 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
var fake = new FakeAcknowledger();
|
var fake = new FakeAcknowledger();
|
||||||
|
|
||||||
var driver = new GalaxyDriver("g", Opts(),
|
var driver = new GalaxyDriver("g", Opts(),
|
||||||
hierarchySource: null, alarmAcknowledger: fake);
|
hierarchySource: null, alarmAcknowledger: fake,
|
||||||
|
ackCredentialResolver: FixedAckCredentialResolver.Fallback());
|
||||||
|
|
||||||
await driver.AcknowledgeAsync(
|
await driver.AcknowledgeAsync(
|
||||||
[Ack("Pump01.Flow.Low", "operator comment")], CancellationToken.None);
|
[Ack("Pump01.Flow.Low", "operator comment")], CancellationToken.None);
|
||||||
|
|
||||||
fake.Calls.ShouldHaveSingleItem();
|
fake.Calls.ShouldHaveSingleItem();
|
||||||
fake.Calls[0].AlarmRef.ShouldBe("Pump01.Flow.Low");
|
fake.Calls[0].AlarmRef.ShouldBe("Pump01.Flow.Low");
|
||||||
fake.Calls[0].Comment.ShouldBe("operator comment");
|
fake.Calls[0].Comment.ShouldBe("operator comment [OPC UA: anonymous]");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -179,10 +185,10 @@ public sealed class GatewayGalaxyAlarmAcknowledgerTests
|
|||||||
|
|
||||||
var ex1 = await Record.ExceptionAsync(
|
var ex1 = await Record.ExceptionAsync(
|
||||||
() => acknowledger.AcknowledgeAsync(
|
() => acknowledger.AcknowledgeAsync(
|
||||||
"Tank01.Level.HiHi", "c1", "alice", CancellationToken.None));
|
"Tank01.Level.HiHi", "c1", Alice, CancellationToken.None));
|
||||||
var ex2 = await Record.ExceptionAsync(
|
var ex2 = await Record.ExceptionAsync(
|
||||||
() => acknowledger.AcknowledgeAsync(
|
() => acknowledger.AcknowledgeAsync(
|
||||||
"Tank01.Level.HiHi", "c2", "alice", CancellationToken.None));
|
"Tank01.Level.HiHi", "c2", Alice, CancellationToken.None));
|
||||||
|
|
||||||
// Both calls must fault — not a success.
|
// Both calls must fault — not a success.
|
||||||
ex1.ShouldNotBeNull("first call to unreachable endpoint should throw");
|
ex1.ShouldNotBeNull("first call to unreachable endpoint should throw");
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IGalaxyAckCredentialResolver"/> test double that resolves every acknowledge to one fixed
|
||||||
|
/// credential (or refuses all), recording the key ids it was asked about.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FixedAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||||
|
{
|
||||||
|
private readonly GalaxyAckCredential? _credential;
|
||||||
|
|
||||||
|
private FixedAckCredentialResolver(GalaxyAckCredential? credential) => _credential = credential;
|
||||||
|
|
||||||
|
/// <summary>Gets the key ids the driver resolved for, in order.</summary>
|
||||||
|
public List<string?> RequestedKeyIds { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>A resolver that always returns a fallback account <c>ACKDOM\ack-op</c> / <c>pw</c>.</summary>
|
||||||
|
public static FixedAckCredentialResolver Fallback() =>
|
||||||
|
new(new GalaxyAckCredential("ack-op", "ACKDOM", "pw", GalaxyAckCredentialSource.Fallback));
|
||||||
|
|
||||||
|
/// <summary>A resolver that refuses every acknowledge.</summary>
|
||||||
|
public static FixedAckCredentialResolver RefuseAll() => new(null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<GalaxyAckCredentialResolution> ResolveAsync(string? apiKeyId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RequestedKeyIds.Add(apiKeyId);
|
||||||
|
return Task.FromResult(_credential is null
|
||||||
|
? GalaxyAckCredentialResolution.Refused("test refusal")
|
||||||
|
: GalaxyAckCredentialResolution.Resolved(_credential));
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ApiKeyAck;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lmxopcua#749 — the AdminUI half of the per-key Galaxy ack identity: written ENCRYPTED to the Secrets
|
||||||
|
/// store under the names the driver reads, listed without touching the password, cleared on request, and
|
||||||
|
/// audited without the credential.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiKeyGalaxyAckIdentityServiceTests
|
||||||
|
{
|
||||||
|
private const string KeyId = "0a1b2c3d4e5f6071";
|
||||||
|
private const string Password = "pw-S3cr3t!";
|
||||||
|
|
||||||
|
private readonly InMemorySecretStore _store = new();
|
||||||
|
private readonly XorCipher _cipher = new();
|
||||||
|
private readonly RecordingAudit _audit = new();
|
||||||
|
private readonly RecordingInvalidator _invalidator = new();
|
||||||
|
|
||||||
|
private ApiKeyGalaxyAckIdentityService Service() => new(_store, _cipher, _audit, _invalidator);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Set_writes_both_secrets_encrypted_under_the_driver_names()
|
||||||
|
{
|
||||||
|
await Service().SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
|
||||||
|
|
||||||
|
var user = _store.Rows[GalaxyAckIdentitySecrets.UserSecretName(KeyId)];
|
||||||
|
var pw = _store.Rows[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)];
|
||||||
|
_cipher.Decrypt(user).ShouldBe("CORP\\alice");
|
||||||
|
_cipher.Decrypt(pw).ShouldBe(Password);
|
||||||
|
|
||||||
|
// At rest it is ciphertext, not the password.
|
||||||
|
Encoding.UTF8.GetString(pw.Ciphertext).ShouldNotContain(Password);
|
||||||
|
pw.Description.ShouldNotBeNull().ShouldNotContain(Password);
|
||||||
|
|
||||||
|
// Password written before the user (a reader never sees a user without its password).
|
||||||
|
_store.UpsertOrder.ShouldBe([GalaxyAckIdentitySecrets.PasswordSecretName(KeyId), GalaxyAckIdentitySecrets.UserSecretName(KeyId)]);
|
||||||
|
_invalidator.Invalidated.ShouldContain(GalaxyAckIdentitySecrets.PasswordSecretName(KeyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_returns_identity_and_never_decrypts_a_password()
|
||||||
|
{
|
||||||
|
var svc = Service();
|
||||||
|
await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
|
||||||
|
await svc.SetAsync("ffff0000ffff0000", "bob", null, "", "admin", default);
|
||||||
|
_store.GetCalls.Clear();
|
||||||
|
|
||||||
|
var list = await svc.ListAsync(default);
|
||||||
|
|
||||||
|
list[KeyId].ShouldBe("CORP\\alice");
|
||||||
|
list["ffff0000ffff0000"].ShouldBe("bob");
|
||||||
|
_store.GetCalls.ShouldAllBe(n => n.EndsWith("/galaxy-ack-user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clear_removes_both_and_audits_without_the_credential()
|
||||||
|
{
|
||||||
|
var svc = Service();
|
||||||
|
await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
|
||||||
|
|
||||||
|
await svc.ClearAsync(KeyId, "admin", default);
|
||||||
|
|
||||||
|
_store.Rows.ShouldBeEmpty();
|
||||||
|
(await svc.ListAsync(default)).ShouldBeEmpty();
|
||||||
|
_audit.Events.Select(e => e.Action).ShouldBe(["apikey.galaxy-ack-identity.set", "apikey.galaxy-ack-identity.clear"]);
|
||||||
|
_audit.Events.ShouldAllBe(e => e.Target == KeyId && e.Actor == "admin");
|
||||||
|
string.Join("|", _audit.Events.Select(e => e.ToString())).ShouldNotContain(Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("CORP\\alice")]
|
||||||
|
public async Task Set_rejects_missing_or_domain_qualified_user(string user)
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<ArgumentException>(() => Service().SetAsync(KeyId, user, null, Password, "admin", default));
|
||||||
|
_store.Rows.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Set_rejects_a_malformed_key_id()
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<ArgumentException>(() => Service().SetAsync("../x", "alice", null, Password, "admin", default));
|
||||||
|
_store.Rows.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reversible stand-in for the AES-GCM envelope cipher — enough to prove the value is not stored in clear.</summary>
|
||||||
|
private sealed class XorCipher : ISecretCipher
|
||||||
|
{
|
||||||
|
public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType) => new()
|
||||||
|
{
|
||||||
|
Name = name, ContentType = contentType,
|
||||||
|
Ciphertext = Encoding.UTF8.GetBytes(plaintext).Select(b => (byte)(b ^ 0x5A)).ToArray(),
|
||||||
|
Nonce = [], Tag = [], WrappedDek = [], WrapNonce = [], WrapTag = [],
|
||||||
|
KekId = "test", Revision = 0, CreatedUtc = DateTimeOffset.UtcNow, UpdatedUtc = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Decrypt(StoredSecret secret) =>
|
||||||
|
Encoding.UTF8.GetString(secret.Ciphertext.Select(b => (byte)(b ^ 0x5A)).ToArray());
|
||||||
|
|
||||||
|
public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingAudit : IAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditEvent> Events { get; } = [];
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingInvalidator : ISecretCacheInvalidator
|
||||||
|
{
|
||||||
|
public List<string> Invalidated { get; } = [];
|
||||||
|
public void Invalidate(SecretName name) => Invalidated.Add(name.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InMemorySecretStore : ISecretStore
|
||||||
|
{
|
||||||
|
public Dictionary<string, StoredSecret> Rows { get; } = new();
|
||||||
|
public List<string> UpsertOrder { get; } = [];
|
||||||
|
public List<string> GetCalls { get; } = [];
|
||||||
|
|
||||||
|
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
|
||||||
|
{
|
||||||
|
GetCalls.Add(name.Value);
|
||||||
|
return Task.FromResult(Rows.TryGetValue(name.Value, out var r) ? r : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
|
||||||
|
{
|
||||||
|
UpsertOrder.Add(row.Name.Value);
|
||||||
|
Rows[row.Name.Value] = row;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
|
||||||
|
Task.FromResult(Rows.Remove(name.Value));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<SecretMetadata>>(Rows.Values.Select(r => new SecretMetadata
|
||||||
|
{
|
||||||
|
Name = r.Name, ContentType = r.ContentType, KekId = r.KekId, Revision = r.Revision,
|
||||||
|
CreatedUtc = r.CreatedUtc, UpdatedUtc = r.UpdatedUtc,
|
||||||
|
}).ToList());
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
|
||||||
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
||||||
|
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -234,4 +234,29 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
|||||||
form.ReconnectReplayOnSessionLost.ShouldBeFalse();
|
form.ReconnectReplayOnSessionLost.ShouldBeFalse();
|
||||||
form.ProbeTimeoutSeconds.ShouldBe(20);
|
form.ProbeTimeoutSeconds.ShouldBe(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lmxopcua#749: the form must carry the AlarmAck fallback through FromRecord/ToRecord, or saving the
|
||||||
|
/// Galaxy driver page would silently drop the fallback account and every anonymous ack would refuse.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FormModel_RoundTrips_AlarmAckFallback()
|
||||||
|
{
|
||||||
|
var original = new GalaxyDriverOptions(
|
||||||
|
new GalaxyGatewayOptions("https://gw:5001", "env:K"),
|
||||||
|
new GalaxyMxAccessOptions("OtOpcUa"),
|
||||||
|
new GalaxyRepositoryOptions(),
|
||||||
|
new GalaxyReconnectOptions())
|
||||||
|
{
|
||||||
|
AlarmAck = new GalaxyAlarmAckOptions("ack-svc", "PLANT", "secret:otopcua/galaxy/ack"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var back = GalaxyDriverForm.GalaxyFormModel.FromRecord(original).ToRecord();
|
||||||
|
back.AlarmAck.ShouldBe(original.AlarmAck);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(back, _pageOpts);
|
||||||
|
json.ShouldContain("\"fallbackPasswordSecretRef\":\"secret:otopcua/galaxy/ack\"");
|
||||||
|
json.ShouldNotContain("hasFallback");
|
||||||
|
JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _pageOpts)!.AlarmAck.ShouldBe(original.AlarmAck);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,6 +644,37 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>lmxopcua#749 — a native Acknowledge carries the session's API key id (not a secret) so the
|
||||||
|
/// Galaxy driver can resolve the key's Galaxy ack identity; an anonymous / key-less session carries null.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Native_OnAcknowledge_carries_the_sessions_ApiKeyId()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var native = new List<NativeAlarmAck>();
|
||||||
|
nm.NativeAlarmAckRouter = native.Add;
|
||||||
|
|
||||||
|
nm.EnsureFolder("eq-nak749", parentNodeId: null, displayName: "Equipment 749", realm: AddressSpaceRealm.Uns);
|
||||||
|
nm.MaterialiseAlarmCondition("alm-nak749", "eq-nak749", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-nak749")!;
|
||||||
|
|
||||||
|
var keyed = new ServerSystemContext(server.CurrentInstance)
|
||||||
|
{
|
||||||
|
UserIdentity = new RoleCarryingUserIdentity(
|
||||||
|
new UserNameIdentityToken { UserName = "apikey" }, [OpcUaDataPlaneRoles.AlarmAck],
|
||||||
|
principal: "mes-line1", apiKeyId: "0a1b2c3d4e5f6071"),
|
||||||
|
};
|
||||||
|
condition.OnAcknowledge!(keyed, condition, EventIdBytes(), new LocalizedText("c1")).ShouldBe(ServiceResult.Good);
|
||||||
|
condition.OnAcknowledge!(SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck), condition, EventIdBytes(), new LocalizedText("c2"));
|
||||||
|
|
||||||
|
native.Count.ShouldBe(2);
|
||||||
|
native[0].ApiKeyId.ShouldBe("0a1b2c3d4e5f6071");
|
||||||
|
native[0].OperatorUser.ShouldBe("mes-line1");
|
||||||
|
native[1].ApiKeyId.ShouldBeNull();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
|
/// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
|
||||||
/// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
|
/// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
|
||||||
/// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
|
/// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
|
||||||
|
|||||||
+4
-2
@@ -60,9 +60,10 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||||
|
|
||||||
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
||||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice", ApiKeyId: "0a1b2c"));
|
||||||
|
|
||||||
// The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
|
// The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal
|
||||||
|
// and the session's API key id (lmxopcua#749 — the Galaxy driver resolves the key's ack identity).
|
||||||
AwaitAssert(() =>
|
AwaitAssert(() =>
|
||||||
{
|
{
|
||||||
recorder.Acks.Count.ShouldBe(1);
|
recorder.Acks.Count.ShouldBe(1);
|
||||||
@@ -70,6 +71,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
|
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
|
||||||
recorder.Acks[0].Comment.ShouldBe("cmt");
|
recorder.Acks[0].Comment.ShouldBe("cmt");
|
||||||
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
||||||
|
recorder.Acks[0].ApiKeyId.ShouldBe("0a1b2c");
|
||||||
}, duration: Timeout);
|
}, duration: Timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user