Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 488649ce99 | |||
| 02228b2717 | |||
| fb20997dc9 | |||
| 463748e395 | |||
| bd82036d5f | |||
| f13950e453 | |||
| 219611c821 | |||
| a09fa0a4d6 | |||
| 3e988ede88 | |||
| e7f56622c1 | |||
| 1998ee3e20 | |||
| 027cc7393d | |||
| 7b81cb7f72 | |||
| 0143591858 | |||
| e8ace39418 | |||
| 9907a97cb0 | |||
| 84e04b6552 | |||
| 417a47bdb5 | |||
| cfe9f817a3 | |||
| 9409488008 | |||
| 2858e42e80 | |||
| d6db0ff40d | |||
| 0d4b2aea2d | |||
| 8199553734 | |||
| 7900df3dce | |||
| 76db822df3 | |||
| 64dc1215fd | |||
| a94b502ce6 | |||
| bf4b9c6c09 | |||
| 91bbdbf154 | |||
| 8c095b9a56 | |||
| 6c82de55c6 | |||
| 6f4c7c3bf1 | |||
| 6affbd57ea | |||
| 6fc423f608 | |||
| 83aa76c33d | |||
| d779daf6af | |||
| 42c120aa83 | |||
| 19920b13da | |||
| a99070005f | |||
| f40409a688 | |||
| f2e0861709 | |||
| 4f931e4b7e | |||
| d16ae4e267 | |||
| 74ba889dea |
@@ -49,7 +49,18 @@ mxaccessgw changes here — remote/push status, the `.proto` contracts (`mxacces
|
||||
`mxaccess_worker.proto`, `galaxy_repository.proto`), the two-process architecture, shared-lib
|
||||
consumption, or per-project commands — update the **MxAccessGateway entry in
|
||||
[`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index
|
||||
never drifts from this repo. (Mirrors the same rule in the peer repos.)
|
||||
never drifts from this repo. (Mirrors the same rule in the peer repos.) **Keep the index row to
|
||||
a few lines** — since 2026-09-03 the umbrella keeps only short summary rows and parks long
|
||||
per-project narrative in `../scadaproj/.claude/skills/sister-project-history/<project>.md`
|
||||
(create `mxaccessgateway.md` there if this gateway ever needs one) and shared-library history
|
||||
in `../scadaproj/<lib>/CLAUDE.md`.
|
||||
|
||||
> ⛔ **Wonder box remote access (2026-09-11): no `psbridge`, no `winrm`.** Both skills are retired/disabled —
|
||||
> the WinRM remote-exec channel set off the security software on the wonder boxes (vd03/vq03/vq04). Do not
|
||||
> run remote commands (service control, publish/swap, PowerShell) on those boxes and do not stand up a
|
||||
> replacement channel until the user clears one. Still allowed: UNC/SMB file copies and logging directly
|
||||
> into the SQL Servers. Deploy steps that need a command on the box are blocked; see the umbrella
|
||||
> [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md) for the current hold.
|
||||
|
||||
## Build, Test, Run
|
||||
|
||||
@@ -115,7 +126,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `docs/DesignDecisions.md` (Session-Resilience Epic Scope, session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
|
||||
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` credentials, and `AcknowledgeAlarm` `operator_password` must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||
|
||||
@@ -112,7 +112,7 @@ The continuation runs serially in the isolated `.worktrees/c3-p2-followups` work
|
||||
| K — client alarm parity | CLI-47/48/49/50 | `gpt-5.6-terra` high | Done | All five client suites green |
|
||||
| L — live watch-list/proto wave | watch-list refresh follow-up + WRK-32 proto comment | `gpt-5.6-sol` high | Done | Codegen clean; portable alarm/contract tests green; windev Worker.Tests green |
|
||||
| M — Windows gateway CI | TST-35 | `gpt-5.6-terra` medium | Done | windev `live` mode proves Worker, gateway, full-slnx, and live smoke tiers |
|
||||
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | In progress | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
|
||||
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | Done | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
|
||||
|
||||
### Continuation tasklist
|
||||
|
||||
@@ -220,12 +220,12 @@ in 53 s; and the live-MXAccess smoke passed **8/8** in 1m46s. TST-35 commit:
|
||||
|
||||
- [x] Run `git diff --check`, codegen, client-version, README, NonWindows build, and full gateway suite gates.
|
||||
- [x] Run all five client suites on the final candidate.
|
||||
- [ ] Run final windev `live` gate at the exact candidate SHA.
|
||||
- [ ] Merge/push the verified continuation to `main`; wait for green CI.
|
||||
- [ ] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
|
||||
- [ ] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
|
||||
- [ ] Create/push `clients/go/v0.2.1` at the exact release commit.
|
||||
- [ ] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
|
||||
- [x] Run final windev `live` gate at the exact candidate SHA.
|
||||
- [x] Merge/push the verified continuation to `main`; wait for green CI.
|
||||
- [x] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
|
||||
- [x] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
|
||||
- [x] Create/push `clients/go/v0.2.1` at the exact release commit.
|
||||
- [x] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
|
||||
|
||||
Batch-N portable verification (2026-08-25): `git diff --check`, codegen freshness,
|
||||
client-version freshness, and the generated README check all passed without changing
|
||||
@@ -236,3 +236,24 @@ green (respectively **134 passed / 1 live skip**, all Go packages built/tested w
|
||||
clean `gofmt`, **169 passed / 1 opt-in TLS skip** from a clean virtual environment,
|
||||
**108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the
|
||||
generated Java tree unchanged).
|
||||
|
||||
Batch-N integration/release verification (2026-08-25): `main` was integrated and
|
||||
released at exact commit `5776021970cbbe59813ba57579abe00d9fc61a46`; Gitea CI
|
||||
run **783** concluded success. Windev `live` mode at that SHA built the x86/net48
|
||||
Worker with **0 warnings / 0 errors**, passed Worker.Tests **529 passed / 11
|
||||
opt-in skips / 0 failed**, built the full Windows solution with **0 warnings / 0
|
||||
errors**, passed the Windows gateway suite **1193/1193**, and passed the live
|
||||
MXAccess smoke **8/8**. One preceding full-suite attempt hit a transient fake-worker
|
||||
disconnect in the reconnect/replay test; the test then passed **50/50** isolated
|
||||
macOS runs and **20/20** isolated windev runs before the clean full-gate pass.
|
||||
|
||||
Immediately before publication, all five package coordinates returned HTTP 404
|
||||
and `clients/go/v0.2.1` was absent locally/remotely. The published coordinates are
|
||||
NuGet `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, PyPI
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, Cargo
|
||||
`zb-mom-ww-mxgateway-client` **0.2.1**, and Maven
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. Each exact Gitea
|
||||
package API coordinate returned HTTP 200 after publication. Annotated Go tag
|
||||
`clients/go/v0.2.1` resolves locally, remotely, and through `go list -m` to
|
||||
`5776021970cbbe59813ba57579abe00d9fc61a46`.
|
||||
|
||||
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
|
||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||
the active alarms the gateway's central monitor currently holds),
|
||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
||||
reference, optional comment, ack target). All three accept a cancellation
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
|
||||
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
|
||||
writes the comment to the alarm's `AckMsg`, so the Historian records the
|
||||
operator). All three accept a cancellation
|
||||
token and pass through the `MxGateway:Alarms` configuration on the
|
||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||
stream rather than failing.
|
||||
@@ -291,10 +293,15 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --se
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 4 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
`acknowledge-alarm` reads the operator password from `--operator-password` or the
|
||||
environment variable named by `--operator-password-env` (default
|
||||
`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from
|
||||
any error output. Prefer the environment variable to keep it off the command line.
|
||||
|
||||
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||
optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||
|
||||
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
|
||||
exception.Message,
|
||||
apiKey,
|
||||
TryResolveVerifyUserPassword(arguments),
|
||||
ResolveOperatorPassword(arguments),
|
||||
arguments.GetOptional("value"));
|
||||
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
@@ -347,13 +348,19 @@ public static class MxGatewayClientCli
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CLI credential environment variable, shared by every official
|
||||
/// client CLI (CLI-45) so one exported variable drives the same operator
|
||||
/// client CLI so one exported variable drives the same operator
|
||||
/// workflow in all five languages.
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback
|
||||
/// Default environment variable holding the operator password for
|
||||
/// <c>acknowledge-alarm</c> when <c>--operator-password-env</c> is not given.
|
||||
/// </summary>
|
||||
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy environment variable, still honoured as a deprecated fallback
|
||||
/// for one release so existing scripts keep working.
|
||||
/// </summary>
|
||||
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
@@ -379,14 +386,38 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in the CLI-45
|
||||
/// Resolves the <c>acknowledge-alarm</c> operator password: <c>--operator-password</c>,
|
||||
/// then the environment variable named by <c>--operator-password-env</c>
|
||||
/// (default <c>MXGATEWAY_OPERATOR_PASSWORD</c>). An empty value is treated as
|
||||
/// absent. The value is never echoed; it is also fed to the error redactor.
|
||||
/// </summary>
|
||||
private static string? ResolveOperatorPassword(CliArguments arguments)
|
||||
{
|
||||
string? password = arguments.GetOptional("operator-password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(environmentName))
|
||||
{
|
||||
environmentName = OperatorPasswordEnvironmentName;
|
||||
}
|
||||
|
||||
password = Environment.GetEnvironmentVariable(environmentName);
|
||||
return string.IsNullOrEmpty(password) ? null : password;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in this
|
||||
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
|
||||
/// alias, the environment variable named by <c>--password-env</c> (default
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
|
||||
/// treated as absent. The credential is never echoed; this resolver exists so
|
||||
/// the error-redaction catch block can strip it from any surfaced error
|
||||
/// (CLI-04), mirroring <see cref="TryResolveApiKey" />.
|
||||
/// the error-redaction catch block can strip it from any surfaced error,
|
||||
/// mirroring <see cref="TryResolveApiKey" />.
|
||||
/// </summary>
|
||||
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
|
||||
{
|
||||
@@ -416,7 +447,7 @@ public static class MxGatewayClientCli
|
||||
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
|
||||
/// a redaction-safe error when no source yields a non-empty value. Failing
|
||||
/// fast keeps a misconfigured environment from becoming a real MXAccess
|
||||
/// authentication attempt with an empty credential (CLI-45); the thrown
|
||||
/// authentication attempt with an empty credential; the thrown
|
||||
/// message names only the option/env var, never the value.
|
||||
/// </summary>
|
||||
private static string ResolveVerifyUserPassword(CliArguments arguments)
|
||||
@@ -758,9 +789,9 @@ public static class MxGatewayClientCli
|
||||
{
|
||||
// The credential is resolved from --password or its env var (default
|
||||
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
|
||||
// fails fast before the invoke rather than reaching the wire (CLI-45).
|
||||
// fails fast before the invoke rather than reaching the wire.
|
||||
// On any surfaced error the RunCoreAsync catch block routes
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr.
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
@@ -1425,7 +1456,7 @@ public static class MxGatewayClientCli
|
||||
else if (gatewayEvent.ReplayGap is { } replayGap)
|
||||
{
|
||||
// Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw
|
||||
// sentinel MxEvent (NEXT-02, mirroring the Go/Python/Rust CLIs).
|
||||
// sentinel MxEvent, mirroring the Go/Python/Rust CLIs.
|
||||
output.WriteLine(FormatReplayGapRow(replayGap));
|
||||
}
|
||||
else
|
||||
@@ -1535,6 +1566,8 @@ public static class MxGatewayClientCli
|
||||
AlarmFullReference = arguments.GetRequired("reference"),
|
||||
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
|
||||
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
|
||||
};
|
||||
|
||||
return WriteReplyAsync(
|
||||
@@ -1849,7 +1882,7 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the
|
||||
/// Formats the typed ReplayGap row shared by the CLIs. Hand-built so the
|
||||
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
|
||||
/// formatter's quoted uint64 strings.
|
||||
/// </summary>
|
||||
@@ -2465,7 +2498,7 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
|
||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = "investigating",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
OperatorDomain = "CORP",
|
||||
});
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
@@ -45,9 +47,35 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
|
||||
Assert.Equal("investigating", call.Request.Comment);
|
||||
Assert.Equal("alice", call.Request.OperatorUser);
|
||||
Assert.Equal("pw-secret", call.Request.OperatorPassword);
|
||||
Assert.Equal("CORP", call.Request.OperatorDomain);
|
||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync redacts the operator password from a failing call's message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
|
||||
// The real transport maps RpcException to MxGatewayException using the status
|
||||
// detail; the fake passes exceptions verbatim, so enqueue the mapped shape.
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret"));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
MxGatewayException ex = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
}));
|
||||
|
||||
Assert.DoesNotContain("pw-secret", ex.Message);
|
||||
Assert.Contains("boom", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -134,6 +134,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -178,6 +179,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
|
||||
/// credential from the flag, and that the credential never appears in stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
|
||||
{
|
||||
@@ -218,9 +220,10 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-04: a surfaced error for authenticate-user must have the credential
|
||||
/// Verifies that a surfaced error for authenticate-user must have the credential
|
||||
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
|
||||
{
|
||||
@@ -248,7 +251,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other
|
||||
/// <c>--password</c> is the primary credential flag, matching the other
|
||||
/// four CLIs. The credential reaches the wire but never stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
@@ -290,10 +293,12 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: the credential is read from the environment variable named by
|
||||
/// the credential is read from the environment variable named by
|
||||
/// <c>--password-env</c>, whose default is the canonical
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
|
||||
/// </summary>
|
||||
/// <param name="passwordEnvArgument">The value passed to <c>--password-env</c>, or null to use the default.</param>
|
||||
/// <param name="environmentName">The environment variable that holds the credential.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
|
||||
@@ -340,7 +345,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: the pre-rename names stay usable for one release — the deprecated
|
||||
/// the pre-rename names stay usable for one release — the deprecated
|
||||
/// <c>--verify-user-password</c> flag and the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
|
||||
/// </summary>
|
||||
@@ -413,7 +418,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
|
||||
/// a missing or empty credential fails fast before the invoke — the CLI
|
||||
/// never sends a fabricated empty password to the wire. The error names the flag
|
||||
/// and the environment variable, never a value.
|
||||
/// </summary>
|
||||
@@ -463,6 +468,9 @@ public sealed class MxGatewayClientCliTests
|
||||
private readonly string _name;
|
||||
private readonly string? _original;
|
||||
|
||||
/// <summary>Sets the environment variable and remembers its original value.</summary>
|
||||
/// <param name="name">The environment variable name.</param>
|
||||
/// <param name="value">The temporary value, or null to clear it.</param>
|
||||
public EnvironmentVariableScope(string name, string? value)
|
||||
{
|
||||
_name = name;
|
||||
@@ -470,6 +478,7 @@ public sealed class MxGatewayClientCliTests
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
/// <summary>Restores the original environment variable value.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(_name, _original);
|
||||
@@ -588,7 +597,7 @@ public sealed class MxGatewayClientCliTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
|
||||
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02).
|
||||
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -754,6 +763,7 @@ public sealed class MxGatewayClientCliTests
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
@@ -777,6 +787,10 @@ public sealed class MxGatewayClientCliTests
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
@@ -788,6 +802,9 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||
Assert.Equal("ack from cli", request.Comment);
|
||||
Assert.Equal("operator1", request.OperatorUser);
|
||||
Assert.Equal("CORP", request.OperatorDomain);
|
||||
Assert.Equal("op-pw-secret", request.OperatorPassword);
|
||||
Assert.DoesNotContain("op-pw-secret", output.ToString());
|
||||
Assert.Contains("ack-fixture", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
|
||||
/// events pass through unchanged with IsReplayGap false.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
|
||||
{
|
||||
@@ -430,6 +431,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_BuildsUnregisterCommand()
|
||||
{
|
||||
@@ -451,6 +453,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
|
||||
{
|
||||
@@ -473,6 +476,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
|
||||
{
|
||||
@@ -498,6 +502,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
|
||||
{
|
||||
@@ -520,6 +525,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
@@ -545,6 +551,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
@@ -568,6 +575,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -598,6 +606,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// natively. The client must surface that scripted failure (as an
|
||||
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
|
||||
{
|
||||
@@ -621,6 +630,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Async_BuildsWriteSecured2Command()
|
||||
{
|
||||
@@ -647,6 +657,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
@@ -675,6 +686,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// must never contain the credential — the error path is built only from
|
||||
/// reply-derived diagnostics, not the request payload.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
|
||||
{
|
||||
@@ -698,6 +710,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySecretRedactionTests
|
||||
{
|
||||
/// <summary>Test: Redact replaces every occurrence of secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_ReplacesEveryOccurrenceOfSecret()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact scrubs both secrets when one is substring of the other.</summary>
|
||||
[Fact]
|
||||
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with null secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with empty secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact ignores whitespace only secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_IgnoresWhitespaceOnlySecret()
|
||||
{
|
||||
@@ -64,6 +69,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redacted preserves concrete subtype and does not chain secret bearing original.</summary>
|
||||
[Fact]
|
||||
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
|
||||
{
|
||||
|
||||
+11
-6
@@ -4,15 +4,16 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the credential-scrub (CLI-40) and malformed-reply (CLI-41) contracts on the
|
||||
/// Tests for the credential-scrub and malformed-reply contracts on the
|
||||
/// credential and id-returning session helpers, driven from shared behavior fixtures.
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySessionReplyContractTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI-40: when MXAccess echoes the submitted credential back in its failure diagnostic,
|
||||
/// Verifies that when MXAccess echoes the submitted credential back in its failure diagnostic,
|
||||
/// the surfaced exception message must scrub it to the library redaction marker.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage()
|
||||
{
|
||||
@@ -33,13 +34,14 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-40: the redacted exception must not leak the echoed credential through any structured
|
||||
/// Verifies that the redacted exception must not leak the echoed credential through any structured
|
||||
/// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic
|
||||
/// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/>
|
||||
/// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT
|
||||
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
|
||||
/// </summary>
|
||||
/// <param name="fixture">The echoed-credential reply fixture to drive.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("authenticate-user.echoed-credential.reply.json")]
|
||||
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
|
||||
@@ -71,9 +73,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// Verifies that an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
@@ -87,9 +90,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// Verifies that an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// resolves to that return value.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue()
|
||||
{
|
||||
@@ -104,9 +108,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
|
||||
/// Verifies that the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
|
||||
/// with neither a typed item handle nor an int32 return_value throws the typed exception.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
|
||||
@@ -32,6 +32,9 @@ public sealed class MxStatusProxyExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the raw success member never overrides the authoritative category.</summary>
|
||||
/// <param name="category">The status category under test.</param>
|
||||
/// <param name="success">The raw success member, which must not influence the result.</param>
|
||||
/// <param name="expected">The expected <c>IsSuccess</c> result.</param>
|
||||
[Theory]
|
||||
[InlineData(MxStatusCategory.Ok, 0, true)]
|
||||
[InlineData(MxStatusCategory.Ok, 1, true)]
|
||||
|
||||
@@ -96,7 +96,8 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Stops and disposes the underlying web application.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||
/// Acknowledges an alarm as the operator. The gateway authenticates
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
|
||||
/// comment to the alarm's AckMsg attribute, so the alarm history records
|
||||
/// the operator. The password is redacted from any surfaced error.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||
/// <param name="request">The acknowledge request: alarm reference, comment, operator credentials.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
return await ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
|
||||
{
|
||||
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -854,6 +854,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task UnregisterAsync(
|
||||
int serverHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -888,6 +889,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task AdviseSupervisoryAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -985,6 +987,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SetBufferedUpdateIntervalAsync(
|
||||
int serverHandle,
|
||||
int updateIntervalMilliseconds,
|
||||
@@ -1125,6 +1128,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecuredAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -1204,6 +1208,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
|
||||
@@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it.
|
||||
|
||||
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
||||
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the
|
||||
operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`,
|
||||
optional `OperatorDomain`) so the gateway authenticates as the operator and the
|
||||
Historian records that operator; an empty password is allowed, and the password
|
||||
is scrubbed from any returned error. The streaming
|
||||
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
||||
stream. All three pass straight through to the gateway's central alarm
|
||||
monitor.
|
||||
@@ -424,7 +428,7 @@ Every subcommand wired into the CLI. All accept the common flags
|
||||
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
|
||||
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
|
||||
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm reference. |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). |
|
||||
| `smoke` | End-to-end smoke workflow against one item. |
|
||||
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
|
||||
| `galaxy-last-deploy` | Print the most recent deploy event. |
|
||||
|
||||
@@ -1119,6 +1119,10 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOperatorPasswordEnv is the CLI environment variable holding the
|
||||
// operator password for acknowledge-alarm.
|
||||
const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -1126,7 +1130,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge (required)")
|
||||
operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)")
|
||||
// Prefer the environment variable so the credential stays out of shell
|
||||
// history and the process table; an empty password is allowed.
|
||||
operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)")
|
||||
operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
if *reference == "" {
|
||||
return errors.New("reference is required")
|
||||
}
|
||||
if *operator == "" {
|
||||
return errors.New("operator is required")
|
||||
}
|
||||
resolvedOperatorPassword := *operatorPassword
|
||||
if resolvedOperatorPassword == "" {
|
||||
envName := *operatorPasswordEnv
|
||||
if envName == "" {
|
||||
envName = defaultOperatorPasswordEnv
|
||||
}
|
||||
resolvedOperatorPassword = os.Getenv(envName)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
@@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
AlarmFullReference: *reference,
|
||||
Comment: *comment,
|
||||
OperatorUser: *operator,
|
||||
OperatorDomain: *operatorDomain,
|
||||
OperatorPassword: resolvedOperatorPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
type AcknowledgeAlarmCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
@@ -3596,6 +3599,10 @@ type AcknowledgeAlarmCommand struct {
|
||||
OperatorNode string `protobuf:"bytes,4,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
|
||||
OperatorDomain string `protobuf:"bytes,5,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
OperatorFullName string `protobuf:"bytes,6,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,7,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
// the snapshot to alarms whose alarm_full_reference starts with the
|
||||
// supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix).
|
||||
@@ -3721,9 +3735,7 @@ func (x *QueryActiveAlarmsCommand) GetAlarmFilterPrefix() string {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
type AcknowledgeAlarmByNameCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
@@ -3739,6 +3751,10 @@ type AcknowledgeAlarmByNameCommand struct {
|
||||
OperatorNode string `protobuf:"bytes,6,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
|
||||
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
OperatorFullName string `protobuf:"bytes,8,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,9,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UnsubscribeBulkCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -6127,11 +6150,11 @@ func (x *DrainEventsReply) GetEvents() []*MxEvent {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
type AcknowledgeAlarmReplyPayload struct {
|
||||
@@ -7217,9 +7240,18 @@ type AcknowledgeAlarmRequest struct {
|
||||
Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"`
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,6,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) Reset() {
|
||||
@@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) GetOperatorDomain() string {
|
||||
if x != nil {
|
||||
return x.OperatorDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type AcknowledgeAlarmReply struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"`
|
||||
ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"`
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
||||
@@ -8992,7 +9037,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\x13AlarmFailoverConfig\x12B\n" +
|
||||
"\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" +
|
||||
"\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\n" +
|
||||
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xf3\x01\n" +
|
||||
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xa0\x02\n" +
|
||||
"\x17AcknowledgeAlarmCommand\x12\x1d\n" +
|
||||
"\n" +
|
||||
"alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\n" +
|
||||
@@ -9000,9 +9045,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\roperator_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" +
|
||||
"\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" +
|
||||
"\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\x12,\n" +
|
||||
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\"J\n" +
|
||||
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\x12+\n" +
|
||||
"\x11operator_password\x18\a \x01(\tR\x10operatorPassword\"J\n" +
|
||||
"\x18QueryActiveAlarmsCommand\x12.\n" +
|
||||
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xbd\x02\n" +
|
||||
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xea\x02\n" +
|
||||
"\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" +
|
||||
"\n" +
|
||||
"alarm_name\x18\x01 \x01(\tR\talarmName\x12#\n" +
|
||||
@@ -9013,7 +9059,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" +
|
||||
"\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" +
|
||||
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\x12,\n" +
|
||||
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\"`\n" +
|
||||
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\x12+\n" +
|
||||
"\x11operator_password\x18\t \x01(\tR\x10operatorPassword\"`\n" +
|
||||
"\x16UnsubscribeBulkCommand\x12#\n" +
|
||||
"\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" +
|
||||
"\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" +
|
||||
@@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"limitValue\x12\x1a\n" +
|
||||
"\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" +
|
||||
"\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" +
|
||||
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" +
|
||||
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xa6\x02\n" +
|
||||
"\x17AcknowledgeAlarmRequest\x122\n" +
|
||||
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
|
||||
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
|
||||
"\acomment\x18\x04 \x01(\tR\acomment\x12#\n" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12+\n" +
|
||||
"\x11operator_password\x18\x06 \x01(\tR\x10operatorPassword\x12'\n" +
|
||||
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomainJ\x04\b\x01\x10\x02R\n" +
|
||||
"session_id\"\xb4\x02\n" +
|
||||
"\x15AcknowledgeAlarmReply\x12%\n" +
|
||||
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
|
||||
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
|
||||
// session; the resulting native MxStatus is returned in the reply.
|
||||
//
|
||||
// OperatorUser is required: the gateway authenticates the operator (with
|
||||
// OperatorPassword and optional OperatorDomain) so the Historian records the
|
||||
// real operator. An empty password is allowed. The password is scrubbed from
|
||||
// any error this method returns.
|
||||
//
|
||||
// Acks are idempotent — re-acking an already-acked condition is a no-op at
|
||||
// the MxAccess layer.
|
||||
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||
@@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
|
||||
|
||||
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
|
||||
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
|
||||
}
|
||||
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
||||
return reply, err
|
||||
return reply, redactSecrets(err, req.GetOperatorPassword())
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
@@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
Comment: "investigating",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
OperatorDomain: "CORP",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeAlarm() error = %v", err)
|
||||
@@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
|
||||
t.Fatalf("captured comment = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" {
|
||||
t.Fatalf("captured operator password mismatch")
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" {
|
||||
t.Fatalf("captured operator domain = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
|
||||
t.Fatalf("authorization metadata = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
acknowledgeError: status.Error(codes.Internal, "boom pw-secret"),
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AcknowledgeAlarm() returned no error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "pw-secret") {
|
||||
t.Fatalf("error leaks operator password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
|
||||
@@ -114,8 +114,11 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
|
||||
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
||||
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
||||
yields alarm-feed messages from the gateway's central monitor), and
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
|
||||
ack target). Close the subscription to cancel the underlying gRPC stream.
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment).
|
||||
The ack carries operator credentials: `operator_user` is required, and
|
||||
`operator_password` / `operator_domain` let the gateway authenticate as that
|
||||
operator so the Historian records the real operator. An empty password is
|
||||
allowed. The password is scrubbed from exceptions the client throws. Close the subscription to cancel the underlying gRPC stream.
|
||||
|
||||
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
|
||||
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
|
||||
@@ -417,7 +420,7 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localho
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 4 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||
```
|
||||
|
||||
|
||||
+1261
-447
File diff suppressed because it is too large
Load Diff
+27
@@ -1822,6 +1822,31 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
|
||||
String operator = "";
|
||||
|
||||
@Option(names = "--operator-domain", description = "Operator Galaxy domain (optional).")
|
||||
String operatorDomain = "";
|
||||
|
||||
@Option(
|
||||
names = "--operator-password",
|
||||
description = "Operator Galaxy password (credential; prefer --operator-password-env). Never echoed.")
|
||||
String operatorPassword;
|
||||
|
||||
@Option(
|
||||
names = "--operator-password-env",
|
||||
defaultValue = "MXGATEWAY_OPERATOR_PASSWORD",
|
||||
description = "Environment variable holding the operator password when --operator-password is omitted.")
|
||||
String operatorPasswordEnv;
|
||||
|
||||
private String resolveOperatorPassword() {
|
||||
if (operatorPassword != null && !operatorPassword.isEmpty()) {
|
||||
return operatorPassword;
|
||||
}
|
||||
String name = operatorPasswordEnv == null || operatorPasswordEnv.isBlank()
|
||||
? "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
: operatorPasswordEnv;
|
||||
String value = System.getenv(name);
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
@@ -1833,6 +1858,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
.setAlarmFullReference(reference)
|
||||
.setComment(comment)
|
||||
.setOperatorUser(operator)
|
||||
.setOperatorDomain(operatorDomain == null ? "" : operatorDomain)
|
||||
.setOperatorPassword(resolveOperatorPassword())
|
||||
.build());
|
||||
writeOutput(
|
||||
"acknowledge-alarm",
|
||||
|
||||
+11
@@ -714,9 +714,16 @@ final class MxGatewayCliTests {
|
||||
"checked",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password",
|
||||
"pw-secret",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("CORP", factory.client.lastAcknowledgeAlarmRequest.getOperatorDomain());
|
||||
assertEquals("pw-secret", factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword());
|
||||
assertFalse(run.output().contains("pw-secret"), run.output());
|
||||
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||
@@ -770,6 +777,10 @@ final class MxGatewayCliTests {
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-password-env",
|
||||
"MXGATEWAY_OPERATOR_PASSWORD",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
|
||||
+27
-4
@@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* worker's MXAccess session; the resulting native MxStatus is returned
|
||||
* in the reply. Acks are idempotent at the MxAccess layer.
|
||||
*
|
||||
* <p>{@code operator_user} is required; the gateway authenticates it with
|
||||
* {@code operator_password} (and optional {@code operator_domain}) and the
|
||||
* Historian records that operator. An empty password is allowed. The
|
||||
* password is scrubbed from any exception this method throws.
|
||||
*
|
||||
* @param request the {@code AcknowledgeAlarmRequest}
|
||||
* @return the raw acknowledge reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
@@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
|
||||
? gatewayError
|
||||
: (MxGatewayException) MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
String secret = request.getOperatorPassword();
|
||||
if (secret.isEmpty()) {
|
||||
throw mapped;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
throw MxGatewayErrors.redacted(mapped, secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,10 +308,24 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
*/
|
||||
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(request));
|
||||
return future.thenApply(reply -> {
|
||||
String secret = request.getOperatorPassword();
|
||||
CompletableFuture<AcknowledgeAlarmReply> checked = future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
if (secret.isEmpty()) {
|
||||
return checked;
|
||||
}
|
||||
// Scrub the operator password from any failure surfaced through the future.
|
||||
return checked.exceptionally(error -> {
|
||||
Throwable cause = error instanceof java.util.concurrent.CompletionException && error.getCause() != null
|
||||
? error.getCause()
|
||||
: error;
|
||||
if (cause instanceof MxGatewayException gatewayError) {
|
||||
throw new java.util.concurrent.CompletionException(MxGatewayErrors.redacted(gatewayError, secret));
|
||||
}
|
||||
throw new java.util.concurrent.CompletionException(cause);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
@@ -28,6 +28,43 @@ final class MxGatewayErrors {
|
||||
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a gateway exception with an exact secret scrubbed from its
|
||||
* message and protocol status. The original is not chained as a cause
|
||||
* because its message and cause chain may carry the secret.
|
||||
*/
|
||||
static MxGatewayException redacted(MxGatewayException error, String secret) {
|
||||
String message = MxGatewaySecrets.redactExact(error.getMessage(), secret);
|
||||
if (error instanceof MxAccessException ex) {
|
||||
return new MxAccessException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewayCommandException ex) {
|
||||
return new MxGatewayCommandException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewaySessionException ex) {
|
||||
return new MxGatewaySessionException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayWorkerException ex) {
|
||||
return new MxGatewayWorkerException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthenticationException) {
|
||||
return new MxGatewayAuthenticationException(message, null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthorizationException) {
|
||||
return new MxGatewayAuthorizationException(message, null);
|
||||
}
|
||||
return new MxGatewayException(message);
|
||||
}
|
||||
|
||||
private static ProtocolStatus scrub(ProtocolStatus status, String secret) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
return status.toBuilder()
|
||||
.setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secret))
|
||||
.build();
|
||||
}
|
||||
|
||||
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
|
||||
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
|
||||
return;
|
||||
|
||||
+46
@@ -74,6 +74,52 @@ final class MxGatewayCredentialReplyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncRedactsOperatorPasswordFromFailure() throws Exception {
|
||||
String serverName = "mxgw-java-ack-" + UUID.randomUUID();
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service = new MxAccessGatewayGrpc.MxAccessGatewayImplBase() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> observer) {
|
||||
observer.onNext(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("auth failed for operator1 password " + CREDENTIAL))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(serverName)
|
||||
.directExecutor().addService(service).build().start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
|
||||
try (MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process").apiKey("").plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5)).build())) {
|
||||
var request = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setOperatorUser("operator1")
|
||||
.setOperatorPassword(CREDENTIAL)
|
||||
.build();
|
||||
|
||||
java.util.concurrent.ExecutionException async = assertThrows(
|
||||
java.util.concurrent.ExecutionException.class,
|
||||
() -> client.acknowledgeAlarmAsync(request).get());
|
||||
assertTrue(async.getCause() instanceof MxGatewayException, String.valueOf(async.getCause()));
|
||||
assertFalse(async.getCause().getMessage().contains(CREDENTIAL));
|
||||
assertFalse(((MxGatewayCommandException) async.getCause()).protocolStatus().getMessage()
|
||||
.contains(CREDENTIAL));
|
||||
|
||||
MxGatewayException sync = assertThrows(MxGatewayException.class, () -> client.acknowledgeAlarm(request));
|
||||
assertFalse(sync.getMessage().contains(CREDENTIAL));
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception {
|
||||
MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json");
|
||||
|
||||
Binary file not shown.
@@ -111,9 +111,15 @@ MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
|
||||
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
|
||||
messages from the gateway's central monitor), and
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
|
||||
and ack target). Cancel the surrounding task or `aclose()` the iterator to
|
||||
terminate the stream.
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
|
||||
comment). The ack is made as the operator: set `operator_user` (required) and
|
||||
`operator_password` (may be empty) and optionally `operator_domain` on
|
||||
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
|
||||
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
|
||||
password is redacted from any raised error.
|
||||
|
||||
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
|
||||
stream.
|
||||
|
||||
`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
@@ -457,7 +463,7 @@ mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --j
|
||||
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
|
||||
mxgw-py stream-events --session-id <id> --max-events 1 --json
|
||||
mxgw-py stream-alarms --max-messages 4 --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
|
||||
```
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import grpc
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .errors import ensure_protocol_success, map_rpc_error
|
||||
from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
from .options import ClientOptions, create_channel, resolve_channel_security
|
||||
@@ -148,17 +148,23 @@ class GatewayClient:
|
||||
self,
|
||||
request: pb.AcknowledgeAlarmRequest,
|
||||
) -> pb.AcknowledgeAlarmReply:
|
||||
"""Acknowledge an active MXAccess alarm condition through the gateway.
|
||||
"""Acknowledge an alarm as the operator.
|
||||
|
||||
The gateway authenticates the request against the API key's
|
||||
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
|
||||
MXAccess session; the resulting native ``MxStatus`` is returned in the
|
||||
reply. Acks are idempotent — re-acking an already-acked condition is a
|
||||
no-op at the MxAccess layer.
|
||||
The gateway authenticates ``operator_user`` with ``operator_password`` and
|
||||
writes the comment to the alarm's ``AckMsg``, so the alarm history records
|
||||
the operator. The password is redacted from any raised error.
|
||||
"""
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
try:
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
except MxGatewayError as error:
|
||||
if request.operator_password:
|
||||
# Imported lazily: session.py imports this module at its tail.
|
||||
from .session import _redact_error
|
||||
|
||||
_redact_error(error, [request.operator_password])
|
||||
raise
|
||||
|
||||
def query_active_alarms(
|
||||
self,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from .auth import redact_secret
|
||||
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
|
||||
@@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
||||
|
||||
|
||||
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
|
||||
def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any:
|
||||
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
||||
|
||||
Operates on a clone so the caller's original reply object is never mutated.
|
||||
Only the free-text diagnostic fields that can echo a client-supplied secret
|
||||
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
||||
Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...):
|
||||
each field is scrubbed only when the message type declares it.
|
||||
"""
|
||||
clone = type(reply)()
|
||||
clone.CopyFrom(reply)
|
||||
if clone.protocol_status.message:
|
||||
fields = clone.DESCRIPTOR.fields_by_name
|
||||
if "protocol_status" in fields and clone.protocol_status.message:
|
||||
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
||||
if clone.diagnostic_message:
|
||||
if "diagnostic_message" in fields and clone.diagnostic_message:
|
||||
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
if "statuses" in fields:
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
return clone
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
|
||||
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
|
||||
#: five languages.
|
||||
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
|
||||
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
@@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
|
||||
@gateway_options
|
||||
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||
@click.option("--operator", default="", help="Operator user name.")
|
||||
@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).")
|
||||
@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).")
|
||||
@click.option(
|
||||
"--operator-password",
|
||||
default=None,
|
||||
help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.",
|
||||
)
|
||||
@click.option(
|
||||
"--operator-password-env",
|
||||
default=DEFAULT_OPERATOR_PASSWORD_ENV,
|
||||
show_default=True,
|
||||
help="Environment variable holding the operator password (an unset variable means an empty password).",
|
||||
)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||
"""Acknowledge an active MXAccess alarm condition as the operator (session-less)."""
|
||||
|
||||
password = _resolve_operator_password(kwargs)
|
||||
kwargs["operator_password"] = password
|
||||
_run(
|
||||
_acknowledge_alarm(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
secrets=_secrets(kwargs) + [password],
|
||||
)
|
||||
|
||||
|
||||
@@ -836,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"userId": user_id}
|
||||
|
||||
|
||||
def _resolve_operator_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the alarm-ack operator password from --operator-password or its env var.
|
||||
|
||||
Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy
|
||||
accounts may have empty passwords. The value is never echoed; callers add it
|
||||
to the ``secrets`` redaction list.
|
||||
"""
|
||||
|
||||
env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV
|
||||
password = kwargs.get("operator_password")
|
||||
if password is None:
|
||||
password = os.environ.get(env_name, "")
|
||||
return password
|
||||
|
||||
|
||||
def _resolve_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the authenticate-user password from --password or --password-env.
|
||||
|
||||
@@ -1161,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
alarm_full_reference=kwargs["reference"],
|
||||
comment=kwargs["comment"],
|
||||
operator_user=kwargs["operator"],
|
||||
operator_domain=kwargs["operator_domain"],
|
||||
operator_password=kwargs["operator_password"],
|
||||
),
|
||||
)
|
||||
return _message_dict(reply)
|
||||
|
||||
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="investigating",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
operator_domain="CORP",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,9 +46,68 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.exception = FakeRpcError(
|
||||
grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret",
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayAuthenticationError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None:
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.replies = [
|
||||
pb.AcknowledgeAlarmReply(
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST,
|
||||
message="rejected credential pw-secret",
|
||||
),
|
||||
),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
raw = excinfo.value.raw_reply
|
||||
assert isinstance(raw, pb.AcknowledgeAlarmReply)
|
||||
assert "pw-secret" not in raw.protocol_status.message
|
||||
assert "pw-secret" not in str(raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
|
||||
@@ -771,6 +771,7 @@ def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) ->
|
||||
|
||||
|
||||
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
|
||||
stub = _BulkFakeStub()
|
||||
stub.acknowledge_alarm = _BulkFakeUnary(
|
||||
[
|
||||
@@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
"investigating",
|
||||
"--operator",
|
||||
"alice",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "pw-secret" not in result.output
|
||||
captured_request = stub.acknowledge_alarm.requests[0]
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
|
||||
@@ -78,7 +78,8 @@ cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --
|
||||
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
||||
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --reference "Area001.Pump001.PumpFault" --operator <user> [--operator-domain <domain>] --json
|
||||
# operator password: MXGATEWAY_OPERATOR_PASSWORD, or --operator-password-env / --operator-password
|
||||
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
||||
```
|
||||
|
||||
@@ -124,6 +125,9 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
|
||||
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
||||
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||
shares the gateway's central alarm monitor with every other client.
|
||||
`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be
|
||||
empty) and `operator_domain`; the gateway authenticates as that operator so the Historian
|
||||
records them. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
|
||||
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
|
||||
@@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may
|
||||
attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
||||
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
||||
`operator_user` is required; `operator_password` may be empty and is scrubbed from any
|
||||
returned error via `Error::redact_secrets`.
|
||||
|
||||
## Galaxy Repository
|
||||
|
||||
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
|
||||
mxgw write-secured2-bulk ...
|
||||
mxgw stream-events --session-id <id> --json
|
||||
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] --operator <user> [--operator-domain <domain>] [--operator-password-env <VAR>=MXGATEWAY_OPERATOR_PASSWORD] [--operator-password <pw>]
|
||||
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
|
||||
@@ -374,8 +374,19 @@ enum Command {
|
||||
reference: String,
|
||||
#[arg(long, default_value = "")]
|
||||
comment: String,
|
||||
#[arg(long, default_value = "")]
|
||||
/// Galaxy account of the acknowledging operator (required; the gateway
|
||||
/// rejects an empty operator so the real operator is recorded).
|
||||
#[arg(long)]
|
||||
operator: String,
|
||||
/// Galaxy domain of the operator; empty authenticates the bare user name.
|
||||
#[arg(long, default_value = "")]
|
||||
operator_domain: String,
|
||||
/// Operator password (prefer `--operator-password-env`; empty is allowed).
|
||||
#[arg(long)]
|
||||
operator_password: Option<String>,
|
||||
/// Environment variable holding the operator password.
|
||||
#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")]
|
||||
operator_password_env: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
@@ -1081,8 +1092,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
reference,
|
||||
comment,
|
||||
operator,
|
||||
operator_domain,
|
||||
operator_password,
|
||||
operator_password_env,
|
||||
json,
|
||||
} => {
|
||||
// An empty password is allowed (Galaxy accounts may have one), so
|
||||
// an unset flag and env resolve to an empty string.
|
||||
let operator_password = operator_password
|
||||
.or_else(|| env::var(&operator_password_env).ok())
|
||||
.unwrap_or_default();
|
||||
let client = connect(connection).await?;
|
||||
let reply = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
@@ -1090,6 +1109,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
alarm_full_reference: reference,
|
||||
comment,
|
||||
operator_user: operator,
|
||||
operator_password,
|
||||
operator_domain,
|
||||
})
|
||||
.await?;
|
||||
print_acknowledge_alarm_reply(&reply, json);
|
||||
@@ -2771,6 +2792,17 @@ mod tests {
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_operator() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"acknowledge-alarm",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_reference() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
|
||||
|
||||
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
|
||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
message AcknowledgeAlarmCommand {
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
string alarm_guid = 1;
|
||||
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
|
||||
string operator_node = 4;
|
||||
string operator_domain = 5;
|
||||
string operator_full_name = 6;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 7;
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
message AcknowledgeAlarmByNameCommand {
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
// may contain dots; the gateway-side parser splits on the first dot
|
||||
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
|
||||
string operator_node = 6;
|
||||
string operator_domain = 7;
|
||||
string operator_full_name = 8;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 9;
|
||||
}
|
||||
|
||||
message UnsubscribeBulkCommand {
|
||||
@@ -725,11 +734,11 @@ message DrainEventsReply {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
message AcknowledgeAlarmReplyPayload {
|
||||
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
string operator_user = 5;
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
string operator_password = 6;
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
string operator_domain = 7;
|
||||
}
|
||||
|
||||
message AcknowledgeAlarmReply {
|
||||
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
|
||||
ProtocolStatus protocol_status = 3;
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
optional int32 hresult = 4;
|
||||
|
||||
@@ -327,6 +327,9 @@ impl GatewayClient {
|
||||
/// MXAccess session; the resulting native MxStatus is returned in the
|
||||
/// reply. Acks are idempotent at the MxAccess layer.
|
||||
///
|
||||
/// `operator_user` is required; `operator_password` may be empty. The
|
||||
/// password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
||||
@@ -336,13 +339,19 @@ impl GatewayClient {
|
||||
&self,
|
||||
request: AcknowledgeAlarmRequest,
|
||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||
let secret = request.operator_password.clone();
|
||||
let mut client = self.inner.clone();
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
let result = async {
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
}
|
||||
.await;
|
||||
// The operator password must never surface in a returned error.
|
||||
result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()]))
|
||||
}
|
||||
|
||||
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
||||
|
||||
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
|
||||
|
||||
impl std::error::Error for MxAccessError {}
|
||||
|
||||
impl Error {
|
||||
/// Scrub exact caller-supplied secrets (for example an operator password)
|
||||
/// from the error's rendered text and any wrapped `tonic::Status`, so that
|
||||
/// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored.
|
||||
#[must_use]
|
||||
pub fn redact_secrets(self, secrets: &[&str]) -> Self {
|
||||
let scrub = |text: &str| {
|
||||
let mut out = text.to_owned();
|
||||
for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
|
||||
out = out.replace(*secret, "<redacted>");
|
||||
}
|
||||
out
|
||||
};
|
||||
let scrub_status = |status: Box<tonic::Status>| {
|
||||
Box::new(tonic::Status::new(status.code(), scrub(status.message())))
|
||||
};
|
||||
match self {
|
||||
Self::Authentication { message, status } => Self::Authentication {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Authorization { message, status } => Self::Authorization {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Timeout { message, status } => Self::Timeout {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Cancelled { message, status } => Self::Cancelled {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Unavailable { message, status } => Self::Unavailable {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Status(status) => Self::Status(scrub_status(status)),
|
||||
Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message,
|
||||
} => Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message: scrub(&message),
|
||||
},
|
||||
Self::MalformedReply { detail } => Self::MalformedReply {
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::InvalidArgument { name, detail } => Self::InvalidArgument {
|
||||
name,
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::MxAccess(error) => Self::MxAccess(Box::new(
|
||||
error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()),
|
||||
)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tonic::Status> for Error {
|
||||
fn from(status: tonic::Status) -> Self {
|
||||
let message = redact_credentials(status.message());
|
||||
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn redact_secrets_scrubs_status_display_and_debug() {
|
||||
let error: super::Error = tonic::Status::internal("boom pw-secret").into();
|
||||
let error = error.redact_secrets(&["pw-secret", ""]);
|
||||
assert!(!format!("{error:?}").contains("pw-secret"));
|
||||
assert!(!error.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
use tonic::{Code, Status};
|
||||
|
||||
use super::{ensure_mxaccess_success, Error};
|
||||
|
||||
@@ -14,6 +14,9 @@ pub mod mxaccess_gateway {
|
||||
/// gateway to language clients.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
// tonic's generated server traits use async_trait, whose expansion adds
|
||||
// redundant must_use attributes to Future returns under Rust 1.99.
|
||||
#![allow(clippy::double_must_use)]
|
||||
|
||||
tonic::include_proto!("mxaccess_gateway.v1");
|
||||
}
|
||||
@@ -36,6 +39,9 @@ pub mod galaxy_repository {
|
||||
/// discovery and deploy-event watch RPCs.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
// tonic's generated server traits use async_trait, whose expansion adds
|
||||
// redundant must_use attributes to Future returns under Rust 1.99.
|
||||
#![allow(clippy::double_must_use)]
|
||||
|
||||
tonic::include_proto!("galaxy_repository.v1");
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: "investigating".to_owned(),
|
||||
operator_user: "alice".to_owned(),
|
||||
operator_password: String::new(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1036,6 +1038,30 @@ async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_alarm_error_does_not_leak_operator_password() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
client_correlation_id: "corr-pw".to_owned(),
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: String::new(),
|
||||
operator_user: "alice".to_owned(),
|
||||
operator_password: "pw-secret".to_owned(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(!format!("{err:?}").contains("pw-secret"));
|
||||
assert!(!err.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
|
||||
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to
|
||||
@@ -1073,6 +1099,8 @@ async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: String::new(),
|
||||
operator_user: String::new(),
|
||||
operator_password: String::new(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway {
|
||||
&self,
|
||||
request: Request<AcknowledgeAlarmRequest>,
|
||||
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
||||
*self.state.last_correlation_id.lock().await =
|
||||
Some(request.into_inner().client_correlation_id);
|
||||
let request = request.into_inner();
|
||||
if request.operator_password == "pw-secret" {
|
||||
return Err(Status::internal("boom pw-secret"));
|
||||
}
|
||||
*self.state.last_correlation_id.lock().await = Some(request.client_correlation_id);
|
||||
Ok(Response::new(AcknowledgeAlarmReply {
|
||||
correlation_id: "corr-1".to_owned(),
|
||||
protocol_status: Some(ok_status("ack ok")),
|
||||
|
||||
@@ -621,8 +621,9 @@ returned to normal but is unacknowledged — i.e., visible in the
|
||||
"current alarms" list because operator hasn't acked it yet) and
|
||||
`UNACK_ALM` (the alarm is currently active and unacknowledged).
|
||||
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
|
||||
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
|
||||
is the method to call.
|
||||
appear when an ack is performed. The alarm-client ack methods
|
||||
(`AlarmAckByGUID` and friends) do not produce them headless; an `AckMsg`
|
||||
write does (`AlarmProbeFindings.md`).
|
||||
|
||||
### `GetStatistics` AV — unrelated quirk
|
||||
|
||||
@@ -660,7 +661,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
||||
events for added/changed/removed records.
|
||||
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
|
||||
fullName)` for client-driven acknowledgements (matches
|
||||
PR A.5's `AlarmAckCommand` payload).
|
||||
PR A.5's `AlarmAckCommand` payload). *Ack step superseded
|
||||
2026-10-19: acks write `AckMsg` as the authenticated operator
|
||||
(see "Acknowledge in subtag mode" and `AlarmProbeFindings.md`).*
|
||||
- Lifecycle teardown: `DeregisterConsumer` +
|
||||
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
|
||||
3. **Conversion layer:** map XML record fields to
|
||||
@@ -701,8 +704,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
||||
`bRetainHiddenAlarms` parameter at all.
|
||||
- Subscription expression format: `\\<machine>\Galaxy!<area>`
|
||||
(literal `Galaxy` provider) for both libraries.
|
||||
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName,
|
||||
node, domain, fullName)` on the v2 surface; ID 5-arg
|
||||
- Native ack surface (not used by the gateway; superseded 2026-10-19 by
|
||||
the authenticated `AckMsg` write): `AlarmAckByGUID(VBGUID guid,
|
||||
comment, oprName, node, domain, fullName)` on the v2 surface; ID 5-arg
|
||||
variant on the legacy `IwwAlarmConsumer` interface.
|
||||
|
||||
These findings retire the open follow-up probes from the
|
||||
@@ -735,25 +739,32 @@ for `SetXmlAlarmQuery`.
|
||||
the canonical `\\<machine>\Galaxy!<area>` form into the XML's
|
||||
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads.
|
||||
|
||||
### 2. Two consumers required: read-side vs. ack-side
|
||||
### 2. Two consumers required: read-side vs. ack-side (historical)
|
||||
|
||||
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
|
||||
> see `AlarmProbeFindings.md`. The worker no longer provisions an ack-only
|
||||
> wnwrap consumer. The text below records why the split existed.
|
||||
|
||||
`SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
|
||||
the same consumer instance**. With SetXml applied, AlarmAckByName
|
||||
returns -55 even with valid name+provider+group+operator. Without
|
||||
SetXml, AlarmAckByName succeeds with rc=0.
|
||||
|
||||
The production consumer therefore provisions **two** wnwrap COM
|
||||
The consumer at the time provisioned **two** wnwrap COM
|
||||
instances:
|
||||
- Primary consumer (`client`): runs full lifecycle including
|
||||
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
|
||||
- Ack-only consumer (`ackClient`): runs Initialize → Register →
|
||||
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**.
|
||||
All `AcknowledgeByName` calls dispatch through this instance.
|
||||
All by-name ack calls dispatched through this instance.
|
||||
|
||||
Both consumers subscribe to the same expression. Disposal cleans up
|
||||
both via a shared `ReleaseConsumerCom` helper.
|
||||
|
||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg
|
||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg (historical)
|
||||
|
||||
> Superseded 2026-10-19: the gateway no longer calls `AlarmAckByName`; see
|
||||
> `AlarmProbeFindings.md`.
|
||||
|
||||
`wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
|
||||
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment,
|
||||
@@ -762,8 +773,8 @@ both via a shared `ReleaseConsumerCom` helper.
|
||||
|
||||
The v2 8-arg method returns -55 on this AVEVA build regardless of
|
||||
operator-identity inputs — looks like a stub. The v1 6-arg method
|
||||
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the
|
||||
6-arg overload and discards the proto's `domain` + `full_name` fields.
|
||||
works. `WnWrapAlarmConsumer.AcknowledgeByName` called the
|
||||
6-arg overload and discarded the proto's `domain` + `full_name` fields.
|
||||
|
||||
**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves
|
||||
of the paragraph above are narrower than they read. The -55 tracks the
|
||||
@@ -775,23 +786,58 @@ snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm
|
||||
extension's own `.Acked` attribute stay `False` for 16 s after each
|
||||
call. On this rig the wnwrap ack is accepted and then inert, so a zero
|
||||
return code must not be read as an applied acknowledgement.
|
||||
The proto contract keeps the 8 fields for forward compatibility if
|
||||
AVEVA fixes the v2 method later.
|
||||
|
||||
### 4. `AlarmAckByGUID` is not implemented
|
||||
### 4. `AlarmAckByGUID` is not implemented (historical)
|
||||
|
||||
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
|
||||
> see `AlarmProbeFindings.md`.
|
||||
|
||||
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
|
||||
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
|
||||
build. The reference→GUID lookup that we initially planned to wire
|
||||
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
|
||||
must go through `AlarmAckByName`.
|
||||
through `AlarmAckByGUID` is therefore not viable on wnwrap.
|
||||
|
||||
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
|
||||
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
|
||||
in the codebase for the forward-compat shape, but the gateway-side
|
||||
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
|
||||
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
|
||||
`Provider!Group.Tag` reference.
|
||||
#### Managed GUID ack experiment — did not work (live-tested 2026-10-18, removed 2026-10-19)
|
||||
|
||||
An opt-in experiment drove `aaAlarmManagedClient.AlarmClient` by reflection
|
||||
(its members are non-public) to take GUID-keyed acks in place of the wnwrap
|
||||
method. The live test showed it could not work, and the experiment and its
|
||||
enabling flag were removed once the `AckMsg` write replaced it. The findings
|
||||
are kept because they explain why the alarm-client route was abandoned.
|
||||
|
||||
The test raised `TestMachine_001.TestAlarm001` with a secured write,
|
||||
read its GUID from the wnwrap XML snapshot (the public API exposes no
|
||||
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
|
||||
with the experiment enabled:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| RPC ack by GUID, experiment off (control) | `NotImplementedException`; `.Acked` stays `False` |
|
||||
| RPC ack by GUID, experiment on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
|
||||
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
|
||||
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
|
||||
|
||||
Three separate problems follow:
|
||||
|
||||
1. **The two libraries cannot share a process.** The worker always runs
|
||||
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
|
||||
The dispatcher is built to drop back to the wnwrap ack path on a
|
||||
lifecycle failure (so alarm reads keep working), and it does so
|
||||
silently — which is why the experiment-on RPC was indistinguishable from the
|
||||
control.
|
||||
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
|
||||
`SelectByGUID` is "not found", not a security refusal, even with the
|
||||
client holding records. Getting the client's own identifier for the
|
||||
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
|
||||
FILETIME `ArgumentOutOfRangeException` described above. The
|
||||
`MxSecurityOperate` question was therefore never reached.
|
||||
3. **The gateway does not send the identity fields.** Its GUID arm fills
|
||||
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
|
||||
strings, so the domain and full name the managed method carries
|
||||
would not arrive through the public RPC even if 1 and 2 were solved.
|
||||
|
||||
The route was dropped in favour of the authenticated `AckMsg` write
|
||||
(`AlarmProbeFindings.md`, "Fourth attempt").
|
||||
|
||||
### 5. STA / threading — resolved
|
||||
|
||||
@@ -947,20 +993,24 @@ AVEVA-internal GUID.
|
||||
|
||||
### Acknowledge in subtag mode
|
||||
|
||||
`AlarmDispatcher` routes ack calls by active provider mode:
|
||||
Both provider modes acknowledge through the worker's `AlarmAckWriter`, which
|
||||
calls `AuthenticateUser` as the requesting operator and writes the comment to
|
||||
the ack item over MXAccess on its own `LMXProxyServer`:
|
||||
|
||||
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
|
||||
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
|
||||
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
|
||||
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
|
||||
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
|
||||
write target).
|
||||
- **Alarm-manager mode:** the item is `<TagName>.AckMsg` for Galaxy-provider
|
||||
alarms. An alarm from another provider is not ackable.
|
||||
- **Subtag mode:** `SubtagAlarmConsumer` resolves the watch-list entry's
|
||||
`ack_comment_subtag` and the writer writes that item verbatim. Writing the
|
||||
`AckMsg` subtag performs the acknowledge in AVEVA (`AckMsg` is the confirmed
|
||||
`AlarmExtension` ack-comment write target).
|
||||
|
||||
If the alarm has no writable ack-comment subtag (`AckComment` config key is
|
||||
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
|
||||
returns a failure code that the gateway surfaces as `FailedPrecondition`.
|
||||
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
|
||||
internal dictionary, then calls the same write path.
|
||||
Because the write is authenticated, the Historian records the operator's name,
|
||||
domain and full name, and the writer's MXAccess client name
|
||||
(`MxGateway:Alarms:AckClientName`) as OperatorNode. If the alarm has no ack
|
||||
target (`AckComment` config key is empty, the entry's `ack_comment_subtag` is
|
||||
empty, or the reference or synthetic GUID is unknown), the worker reports it
|
||||
as not found or not ackable and the gateway surfaces `InvalidRequest` with
|
||||
`hresult` -1.
|
||||
|
||||
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
|
||||
observed ones (active/acked/priority). This is required: MXAccess rejects a
|
||||
@@ -978,7 +1028,7 @@ The subtag path was validated against live MXAccess on the dev rig
|
||||
`….Priority` → `500` (Int32), `….AckMsg` → string — confirming the field
|
||||
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
|
||||
with **no** intermediate alarm-condition segment.
|
||||
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
|
||||
- The `AckMsg` write returned `0` once the ack-comment subtag
|
||||
was advised — confirming the ack-by-comment-write mechanism end to end.
|
||||
|
||||
### Fidelity limitations
|
||||
|
||||
+127
-2
@@ -24,13 +24,15 @@ the blocker rather than rediscovering it.
|
||||
|---|---|
|
||||
| GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) |
|
||||
| GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID |
|
||||
| GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy |
|
||||
| GUID stable across `UNACK → ACK` | Blocker resolved 2026-10-19 — the rig is driven to `ACK_ALM` by an `AckMsg` write, and the `MxSecurityOperate` classification was not the cause (see "Fourth attempt"); GUID survival across that transition has not been recorded yet |
|
||||
| Acknowledge an alarm headless | Answered 2026-10-19 — **an `AckMsg` write as the authenticated operator**; every alarm-client ack is inert (see "Fourth attempt") |
|
||||
| `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** |
|
||||
|
||||
The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The
|
||||
2026-08-18 run cleared that blocker with `AuthenticateUser` + `WriteSecured` and answered
|
||||
two of the three questions; the acknowledge leg is now blocked on something narrower and
|
||||
different, described in "Second attempt" and diagnosed in "Third attempt".
|
||||
different, described in "Second attempt" and diagnosed in "Third attempt"
|
||||
(resolved in the Fourth attempt, 2026-10-19).
|
||||
|
||||
## First attempt (2026-08-17): plain `Write`
|
||||
|
||||
@@ -420,3 +422,126 @@ The second attempt's inventory, unchanged since: the three
|
||||
snapshot, since nothing can acknowledge them away. `SnapshotActiveAlarms` counts only
|
||||
`UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on
|
||||
the next `alarmmgr` restart.
|
||||
|
||||
## Fourth attempt (2026-10-19): authenticated AckMsg write
|
||||
|
||||
The third attempt left the acknowledge leg blocked. This run found a route that
|
||||
acknowledges and identifies the operator, and ruled out the alarm-client routes.
|
||||
It drove `TestMachine_001.TestAlarm001`, raised with
|
||||
`mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`.
|
||||
|
||||
### Writing `AckMsg` acknowledges
|
||||
|
||||
Writing the comment to `TestMachine_001.TestAlarm001.AckMsg` over MXAccess
|
||||
acknowledges the alarm: `.Acked` becomes `True` and the wnwrap record moves to
|
||||
`STATE=ACK_ALM` with an `ACK_COMMENT`. `AckMsg` is `MxSecurityFreeAccess` even
|
||||
though the alarm UDA itself is `MxSecurityOperate`, so no Galaxy change is
|
||||
needed.
|
||||
|
||||
What the Historian records, read from `Runtime.dbo.v_AlarmEventHistory2`:
|
||||
|
||||
| Write | Operator | DomainName | UserFullName | OperatorNode | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| Unauthenticated `Write` | `NULL` | `NULL` | `NULL` | `NULL` | the comment |
|
||||
| `AuthenticateUser` + `Write(value, userId)` | `Administrator` | `ArchestrA` | `Administrator` | `mxa` (the MXAccess `Register()` client name) | the comment |
|
||||
|
||||
The wnwrap XML also gains `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`,
|
||||
`ACK_OPERATOR_NODE` and `ACK_COMMENT` on the acknowledged record; the worker's
|
||||
snapshot parser reads them for Acknowledge transitions. An unauthenticated write
|
||||
therefore acknowledges but leaves no operator in the Historian, which is why the
|
||||
gateway requires `operator_user` and authenticates it.
|
||||
|
||||
Another actor's `AckMsg` writes on the same day (authenticated as another
|
||||
operator account) logged that account as `Operator`, `Domain="."` and that machine's name as the node,
|
||||
which matches the client-name rule. By this date unauthenticated writes to
|
||||
`MxSecurityOperate` attributes were also accepted Galaxy-wide on the rig, where
|
||||
August refused them with 1008.
|
||||
|
||||
### Alarm-client acks are inert in every variant
|
||||
|
||||
All of these returned `rc=0` or `-1` and changed neither the snapshot, `.Acked`
|
||||
nor the Historian:
|
||||
|
||||
- wnwrap `AlarmAckByName` and `AlarmAckByGUID` (`E_NOTIMPL`) on the ack-only and
|
||||
reader consumers.
|
||||
- wnwrap `IwwAlarmConsumer2` on a sole consumer with `SetXmlAlarmQuery` applied
|
||||
(list populated, `GetAlarm(0)` returns the row): `AlarmAck` with ids
|
||||
0/1/2/3/-1/65536, and `Select(0)` + `AlarmAckSelected`, with operators
|
||||
`TestOperator`, `Administrator` and a third account. `SelectByGUID` is `E_NOTIMPL`.
|
||||
- The same calls with `TestAlarm001` temporarily reclassified to
|
||||
`MxSecurityFreeAccess` (template and instance, restored afterwards), so the
|
||||
security classification is not the cause.
|
||||
- Managed `aaAlarmManagedClient`: `AlarmAckByGUID` and `SelectByGUID` return
|
||||
`-1` (the client's list never fills headless, even with a real `hWnd` and a
|
||||
message pump); `AckSelectedTagAlarms_Ex` returns `0` even for a
|
||||
`NOSUCHPROVIDER` provider and writes no Historian row. The managed and wnwrap
|
||||
consumers cannot share a process (`RegisterConsumer` returns `-65`).
|
||||
- A 20 s continuous message pump after `AlarmAck`, and registering as `InTouch`,
|
||||
`WindowViewer` or `Wonderware InTouch`.
|
||||
|
||||
Static reverse engineering matched: the five-string COM acks forward domain and
|
||||
full name with an empty password, and InTouch's `AlarmViewer.ocx` also sends an
|
||||
empty password. The user-request call is synchronous request-to-return-code with
|
||||
no completion channel, so the drop happens provider-side. Hooking the user
|
||||
request in a live WindowViewer that acknowledges successfully and diffing it
|
||||
against the headless probe is the one definitive step not taken.
|
||||
|
||||
### Consequence
|
||||
|
||||
The gateway acknowledges by authenticating the operator and writing `AckMsg`
|
||||
(`docs/DesignDecisions.md`, "Alarm acknowledgement writes AckMsg as the
|
||||
authenticated operator"). The managed ack consumer and the second wnwrap ack
|
||||
consumer were removed.
|
||||
|
||||
### Gateway-level verification
|
||||
|
||||
The same result holds end to end through the gateway. With the feature build deployed on
|
||||
the windev rig and `TestAlarm001` raised, an `AcknowledgeAlarm` RPC for
|
||||
`Galaxy!TestArea.TestMachine_001.TestAlarm001` with `operator_user=Administrator` and an
|
||||
empty password returned `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. `.Acked` turned True,
|
||||
`.AckMsg` held the comment, and the Historian logged the `ACK_ALM` row with
|
||||
`Operator=Administrator`, `DomainName=ArchestrA`, `UserFullName=Administrator`,
|
||||
`OperatorNode=MxGateway` (the default `AckClientName`), and `Description` = the comment.
|
||||
|
||||
A wrong password, or a user Galaxy security does not know, makes `AuthenticateUser`
|
||||
throw `ArgumentException` (`E_INVALIDARG`, 0x80070057) rather than a `COMException`.
|
||||
That is why the writer treats `ArgumentException` from the MXAccess call itself as an
|
||||
interop failure alongside `COMException`, and reports the HRESULT the exception carries.
|
||||
Such an ack returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked,
|
||||
the Historian gains no ack row, and the password string does not appear in the gateway
|
||||
log, `stdout.log`, or `stderr.log`. A non-Galaxy provider reference returns
|
||||
`PROTOCOL_STATUS_CODE_INVALID_REQUEST` with `hresult` -1, and an empty `operator_user`
|
||||
returns gRPC `InvalidArgument`.
|
||||
|
||||
The GUID arm of the ack command was not driven live. The public alarm RPCs expose only
|
||||
the full reference, so clients reach the by-name arm; worker unit tests cover the GUID
|
||||
arm.
|
||||
|
||||
## Item handle reuse
|
||||
|
||||
`AlarmAckWriter` correlates an ack with its `OnWriteComplete` by the
|
||||
(server handle, item handle) pair, because the callback carries no per-write id.
|
||||
After an unconfirmed ack it drops the `AckMsg` item and re-adds it so the next
|
||||
ack waits on a new handle that a late completion for the old one cannot match.
|
||||
That only works if MXAccess does not hand the same number out again.
|
||||
|
||||
`ItemHandleReuseProbeTests` (Skip-gated, worker tests) checked this on windev
|
||||
(`10.100.0.48`, Galaxy `DEV`) on 2026-10-20 against
|
||||
`TestMachine_001.TestAlarm001.AckMsg`, without writing:
|
||||
|
||||
| Sequence on one registration (server handle 1) | Item handles returned |
|
||||
|---|---|
|
||||
| `AddItem` + `Advise`, then `UnAdvise` + `RemoveItem`, five times | 1, 2, 3, 4, 5 |
|
||||
| `AddItem`, then `RemoveItem`, five times | 6, 7, 8, 9, 10 |
|
||||
| Add and remove `AckMsg`, add `Acked`, add `AckMsg` again | 11, then 12 (`Acked`), 13 |
|
||||
| Add `AckMsg` twice while both are held | 14, 15 |
|
||||
|
||||
Within a registration the numbers increase and a freed number is never reused,
|
||||
so the drop-and-re-add mitigation holds. Numbering is per registration:
|
||||
`Unregister` + `Register` on the same COM object returned server handle 2 and
|
||||
item handle 1, and a new `LMXProxyServerClass` returned server handle 1 and
|
||||
item handle 1 again. The writer is not exposed to that restart because its
|
||||
teardown detaches its `OnWriteComplete` handler from the old COM object and
|
||||
clears that server handle's completion entries before it creates a new one.
|
||||
No per-handle generation counter is needed.
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request)
|
||||
}
|
||||
```
|
||||
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. The trade-off is that an `invoke:write` key can tell `AuthenticationFailed` (an `MXACCESS_FAILURE` whose diagnostic reports operator authentication failure) apart from every other outcome, which makes `AcknowledgeAlarm` a Galaxy-password oracle for that key — whereas the `AuthenticateUser` command itself requires `invoke:secure`. What bounds it: the caller must already hold a valid, admin-issued, revocable `invoke:write` key, and every acknowledgement is serialized through the single monitor worker session's STA, so guesses run one at a time. The gateway does **not** throttle failed operator authentications: the `ApiKeyFailureLimiter` counts only failed API-key verifications, and the ASP.NET Core rate limiter covers only the dashboard `/login` endpoint. Any lockout after repeated bad passwords comes from the Galaxy security configuration, not from the gateway. Grant `invoke:write` only to clients trusted with that exposure. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
|
||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||
|
||||
|
||||
+10
-7
@@ -140,13 +140,16 @@ publish while the version constants still read the already-published
|
||||
0.2.0/0.2.1 numbers above — see `clients/python/README.md` for the
|
||||
user-facing warning. **0.2.0 is superseded by 0.2.1; do not install it.**
|
||||
|
||||
As of this remediation (CLI-46), the in-source version constants for the
|
||||
.NET, Go, Python, and Rust clients (plus Contracts) read **0.2.1**, and the
|
||||
Java client reads **0.2.2**. These are not yet published — the numbers
|
||||
above (0.2.0 four clients + Contracts, 0.2.1 Java) remain the live registry
|
||||
state until the next `pack-clients.ps1` / `tag-go-module.ps1` run. Once that
|
||||
publish lands, update this section with the new coordinates and commit(s),
|
||||
the same way the 2026-08-07 record above was written after the fact.
|
||||
On 2026-08-25 the superseding release shipped from commit
|
||||
`5776021970cbbe59813ba57579abe00d9fc61a46`. Published coordinates on
|
||||
`gitea.dohertylan.com` are `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, `pypi`
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, `cargo`
|
||||
`zb-mom-ww-mxgateway-client` **0.2.1**, and `maven`
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. The Go module
|
||||
tag `clients/go/v0.2.1` points to that same commit. Every exact package
|
||||
coordinate returned HTTP 200 through the Gitea package API after publication,
|
||||
and `go list -m` resolved `v0.2.1` to the tagged commit.
|
||||
|
||||
## .NET
|
||||
|
||||
|
||||
@@ -49,6 +49,17 @@ worker prepares the replacement on its STA before swapping it into the existing
|
||||
failover consumer and returns a normal `MxCommandReply`; callers must treat only
|
||||
`protocol_status = OK` as acknowledgement of the new list.
|
||||
|
||||
`AcknowledgeAlarmRequest` carries the operator's identity: `operator_user`
|
||||
(required), `operator_password` (field 6) and `operator_domain` (field 7). The
|
||||
gateway copies them onto `AcknowledgeAlarmCommand` (`operator_password` = 7) and
|
||||
`AcknowledgeAlarmByNameCommand` (`operator_password` = 9); the worker
|
||||
authenticates the operator and writes `AckMsg` as that user. The fields are
|
||||
additive, so `GatewayProtocolVersion` stays `3`. `operator_password` is a
|
||||
credential and follows the same never-log rule as
|
||||
`AuthenticateUserCommand.verify_user_password`: it must not reach logs, metric
|
||||
labels, `DiagnosticMessage`, `ProtocolStatus.Message`, or exception text. A
|
||||
not-found or not-ackable alarm is reported with `hresult` -1.
|
||||
|
||||
The command model also includes bulk write/read command kinds:
|
||||
`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and
|
||||
`ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway`
|
||||
|
||||
+50
-8
@@ -703,14 +703,15 @@ latch in subtag mode.
|
||||
|
||||
### Acknowledge via ack-comment write
|
||||
|
||||
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
|
||||
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
|
||||
performs the native ack in AVEVA. This differs from alarmmgr mode, where
|
||||
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
|
||||
subtag name is empty by default; configuring it is required for ack to work in
|
||||
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
|
||||
config block exists precisely so names are not guessed without validation
|
||||
against the live MXAccess attribute set.
|
||||
Both provider modes acknowledge by writing the operator comment to `AckMsg` as
|
||||
the authenticated operator (see "Alarm acknowledgement writes AckMsg as the
|
||||
authenticated operator" below). In alarm-manager mode the target is
|
||||
`<TagName>.AckMsg`; in subtag mode it is the watch-list entry's ack-comment
|
||||
subtag (`Fallback:Subtags:AckComment`, default `AckMsg`). The write performs
|
||||
the ack in AVEVA. An entry with an empty ack-comment subtag is not ackable and
|
||||
the RPC reports `InvalidRequest`. The exact AVEVA subtag names are not
|
||||
hard-coded — the `Subtags` config block exists so names are not guessed
|
||||
without validation against the live MXAccess attribute set.
|
||||
|
||||
### Related documentation
|
||||
|
||||
@@ -784,6 +785,47 @@ The following items were previously listed here and have since shipped:
|
||||
`MxGateway:Sessions:MaxEventSubscribersPerSession`.
|
||||
See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md).
|
||||
|
||||
## Alarm acknowledgement writes AckMsg as the authenticated operator
|
||||
|
||||
**Decision (2026-10-19).** `AcknowledgeAlarm` authenticates the requesting
|
||||
operator with `AuthenticateUser` (`operator_user`, `operator_password`,
|
||||
optional `operator_domain`) and writes the comment to `<TagName>.AckMsg` over
|
||||
MXAccess as that user, on a dedicated `LMXProxyServer` registered as
|
||||
`MxGateway:Alarms:AckClientName`. Both provider modes use this path.
|
||||
|
||||
**Why.** Live tests (2026-10-18/19) showed every alarm-client ack is inert on a
|
||||
headless consumer: wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/
|
||||
`AlarmAckSelected`, and the managed `aaAlarmManagedClient` GUID ack, return
|
||||
rc=0 or -1 and change nothing, regardless of security classification,
|
||||
operator name or consumer name. An MXAccess write to `AckMsg` does acknowledge
|
||||
the alarm, and when the write is made under an authenticated user the AVEVA
|
||||
runtime historizes that identity (`Operator`, `DomainName`, `UserFullName`),
|
||||
with the MXAccess client name as `OperatorNode`. The requirement is that the
|
||||
real operator appears in the Historian, as with InTouch, so a shared gateway
|
||||
service account was rejected. Operators have Galaxy accounts, so the
|
||||
per-request credential is workable. See
|
||||
[Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- `operator_user` is required; `operator_password` may be empty.
|
||||
- `operator_password` is a credential. It is forwarded to the worker over the
|
||||
pipe but never logged: `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are
|
||||
credential-bearing commands and any property named like `password` is
|
||||
redacted.
|
||||
- `Applied` and `Unconfirmed` (write sent, no `OnWriteComplete` in the bounded
|
||||
wait) both report `OK`; clients that need certainty watch for the transition.
|
||||
- The managed ack consumer, the WNAL native ack consumer, the second wnwrap
|
||||
ack-only consumer and the opt-in managed-ack environment flag are removed.
|
||||
|
||||
**Upgrade note.** This is a breaking change for callers. They must now send
|
||||
`operator_user` (required; empty is rejected with `InvalidArgument`) and the
|
||||
operator's Galaxy password; a caller that sends no password for a
|
||||
password-protected account gets `MXACCESS_FAILURE` from `AuthenticateUser`
|
||||
instead of an acknowledgement. OtOpcUa's `GalaxyDriver` alarm acknowledger is a
|
||||
known caller that must be updated to forward the operator's credentials before
|
||||
the gateway is upgraded.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and
|
||||
|
||||
## Goals
|
||||
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, `AcknowledgeAlarm` (its `operator_password`), or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
|
||||
A second goal is parity-test diagnosability. Because MXAccess sessions, workers, correlation ids, and command methods are the units of comparison, every log entry produced inside a request scope must carry those identifiers without each call site having to format them.
|
||||
|
||||
@@ -70,7 +70,9 @@ private static readonly HashSet<string> SensitiveCommandMethods = new(StringComp
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
"WriteSecured2",
|
||||
"AcknowledgeAlarm",
|
||||
"AcknowledgeAlarmByName"
|
||||
};
|
||||
|
||||
public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
@@ -80,7 +82,7 @@ public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
}
|
||||
```
|
||||
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below.
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below. `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are in the set because their commands carry the operator's `operator_password`. Independently of the method set, the name-based seam redacts any property whose name contains `password`, so a credential field added to a payload later is redacted without a code change.
|
||||
|
||||
### API key redaction
|
||||
|
||||
|
||||
@@ -442,10 +442,19 @@ behavior.
|
||||
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor re-resolves and, on material change, hot-swaps the subtag fallback watch-list, then reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot. The acknowledged watch-list fingerprint advances only after an OK worker reply. Floored at 5 seconds. |
|
||||
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be between `100` and `3600000` (one hour): every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path, while a value above an hour stops being a cadence and silently disables alarm polling. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or out-of-range value leaves the worker on the 500 ms default rather than failing the session. |
|
||||
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be between `64` and `65536` — the worker is a 32-bit process that materializes each reply as one BSTR plus a full `XmlDocument`, so an unbounded cap faults the STA with an out-of-memory rather than merely slowing it. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning to its stderr (identifiers and counts only). **Remediation when you see that warning: raise this value** so the steady-state active-alarm count fits inside one fetch. A galaxy permanently above the cap holds stale entries in the snapshot until a sub-cap poll, and loses clear→re-raise cycles that happen entirely out of window (see `docs/DesignDecisions.md`). Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or out-of-range value leaves the worker on the 1024 default. |
|
||||
| `MxGateway:Alarms:AckClientName` | `MxGateway` | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via `MXGATEWAY_ALARM_ACK_CLIENT_NAME`. |
|
||||
|
||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||
|
||||
`AckClientName` is the MXAccess client name the alarm worker's ack writer
|
||||
registers its dedicated `LMXProxyServer` with. The Historian records it as the
|
||||
OperatorNode of each acknowledgement, so it identifies the gateway as the
|
||||
acknowledging node. The gateway passes it to each worker through the
|
||||
`MXGATEWAY_ALARM_ACK_CLIENT_NAME` environment variable. Startup validation
|
||||
rejects an empty value, a value over 64 characters, or one containing control
|
||||
characters.
|
||||
|
||||
### Alarm fallback options
|
||||
|
||||
The `Fallback` sub-section controls how the alarm feed selects between the
|
||||
@@ -463,7 +472,7 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback.
|
||||
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the merged watch-list (case-insensitive). The exclude is applied after the Repository-derived rows and the explicit `IncludeAttributes` entries are combined, so an exclude that matches an explicit include suppresses it too — excludes win. Ignored when `UseGalaxyRepository` is `false`. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag, as the authenticated operator, performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the alarm is not ackable and the RPC returns `InvalidRequest`. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
|
||||
Validation rules:
|
||||
|
||||
+24
-2
@@ -96,9 +96,30 @@ Selected control commands have additional public behavior beyond their protobuf
|
||||
|
||||
### `AcknowledgeAlarm`
|
||||
|
||||
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` and `operator_user` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||
|
||||
An `OK` response means the alarm provider accepted the acknowledgement, not that it applied it: the gateway forwards the ack and reports what the provider returned, in keeping with the parity rule. On galaxies whose alarmed attributes carry a non-free-access security classification the by-name ack is accepted and inert — the underlying `AlarmAckByName` conveys an operator name rather than an authenticated identity, observed on the probe rig with the mechanism inferred rather than confirmed — so a client that needs to confirm an acknowledgement should watch for the resulting transition on `StreamAlarms` instead of treating `OK` as proof. See [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
Request fields that identify the acknowledging operator:
|
||||
|
||||
- `operator_user` (required): the Galaxy account name. An empty value is rejected with `InvalidArgument` so the real operator is always recorded.
|
||||
- `operator_password` (field 6): the operator's Galaxy password. May be empty because Galaxy accounts can have empty passwords. It is a credential and is never logged.
|
||||
- `operator_domain` (field 7): optional. When set, the worker authenticates `domain\user`; when empty it authenticates `user`.
|
||||
|
||||
The worker calls `AuthenticateUser` as the operator, then writes `operator_comment` to `<TagName>.AckMsg` (subtag mode: the configured ack-comment subtag) on a dedicated MXAccess server registered as `MxGateway:Alarms:AckClientName`, and waits a bounded time for `OnWriteComplete`. The AVEVA runtime treats that write as the acknowledgement, and the Historian (`Runtime.dbo.v_AlarmEventHistory2`) records the operator name, domain and full name from the authenticated account, with the ack client name as `OperatorNode`. The alarm-client ack calls (`AlarmAckByName`, `AlarmAckByGUID`) are not used because a headless consumer's acks are accepted and dropped; see [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
| Worker outcome | Response status | `hresult` |
|
||||
|---|---|---|
|
||||
| Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` |
|
||||
| Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` |
|
||||
| `AuthenticateUser` raised an MXAccess error (bad user or wrong password surfaces as `E_INVALIDARG` `0x80070057`, raised by the interop as `ArgumentException`; `COMException` and `UnauthorizedAccessException` are classified the same way) | `MXACCESS_FAILURE` | the exception's HRESULT |
|
||||
| `AuthenticateUser` returned user id `0` | `MXACCESS_FAILURE` | `-1` |
|
||||
| `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT |
|
||||
| `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT |
|
||||
| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` |
|
||||
| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an exception from the worker's own code rather than from an MXAccess call: interop failures are classified only when the `AuthenticateUser`, `AddItem`, `Advise` or `Write` call itself raises them, and an `ArgumentNullException` or `ArgumentOutOfRangeException` is never classified because the interop does not raise them for an HRESULT) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT |
|
||||
|
||||
`reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md).
|
||||
|
||||
**Upgrade note.** Callers must now send `operator_user` (required) and the operator's Galaxy password in `operator_password`. A caller that sends no `operator_user` gets `InvalidArgument`; a caller that sends no password for a password-protected Galaxy account gets `MXACCESS_FAILURE` from `AuthenticateUser` instead of an acknowledgement. Every existing caller must be updated before the gateway is upgraded — known callers include OtOpcUa's `GalaxyDriver` alarm acknowledger, which must supply the operator's credentials.
|
||||
|
||||
### `StreamAlarms`
|
||||
|
||||
@@ -217,6 +238,7 @@ receive this event directly.
|
||||
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `operator_user` must be non-empty. Validated inline in the handler. | `InvalidArgument` |
|
||||
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
|
||||
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
|
||||
|
||||
|
||||
@@ -351,6 +351,23 @@ native client correlating by handle has the identical ambiguity). The exposure
|
||||
is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus`
|
||||
stay acceptance-only regardless of which completion (if any) was attributed.
|
||||
|
||||
**The alarm ack writer narrows the window for acknowledgements.**
|
||||
`AlarmAckWriter` registers its own MXAccess server with its own
|
||||
`MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm
|
||||
(least-recently-used, capped at 256). After an ack ends unconfirmed it marks the
|
||||
item dirty, and the next ack to that alarm removes and re-adds the item so the
|
||||
write waits on a fresh item handle the stale completion cannot match. This
|
||||
relies on MXAccess never reusing an item handle number within one
|
||||
registration, which a live probe confirmed (see "Item handle reuse" in
|
||||
[Alarm Probe Findings](./AlarmProbeFindings.md)). Every
|
||||
drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`)
|
||||
also removes that handle's completion entry, and tearing down the registration
|
||||
removes every entry for its server handle, so the cache does not grow by one
|
||||
entry per dropped handle for the life of the worker. The writer records a
|
||||
completion only for an item handle it currently holds on its live
|
||||
registration, so a late `OnWriteComplete` for a dropped handle cannot recreate
|
||||
its entry.
|
||||
|
||||
## Live alarm watch-list replacement
|
||||
|
||||
An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a
|
||||
|
||||
@@ -394,12 +394,12 @@ Query every exact registry coordinate for HTTP 200 and resolve the Go module/tag
|
||||
|
||||
## Final Tasklist Exit Gate
|
||||
|
||||
- [ ] All 16 open P2 findings are Done with commit/test evidence.
|
||||
- [ ] WRK-34 is corrected to Done (`3679e59`).
|
||||
- [ ] Live watch-list refresh passes portable and windev tests.
|
||||
- [ ] WRK-32 public proto comment is regenerated through every client surface.
|
||||
- [ ] TST-35 nightly Windows gateway suite is proven on windev.
|
||||
- [ ] Full NonWindows gateway suite and all five client suites pass.
|
||||
- [ ] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
|
||||
- [ ] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
|
||||
- [ ] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work.
|
||||
- [x] All 16 open P2 findings are Done with commit/test evidence.
|
||||
- [x] WRK-34 is corrected to Done (`3679e59`).
|
||||
- [x] Live watch-list refresh passes portable and windev tests.
|
||||
- [x] WRK-32 public proto comment is regenerated through every client surface.
|
||||
- [x] TST-35 nightly Windows gateway suite is proven on windev.
|
||||
- [x] Full NonWindows gateway suite and all five client suites pass.
|
||||
- [x] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
|
||||
- [x] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
|
||||
- [x] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+22
-18
@@ -140,8 +140,9 @@ one gateway-owned worker session subscribes the configured AVEVA alarm
|
||||
provider, caches the active-alarm set (reconciled periodically against the
|
||||
worker's snapshot), and fans it out to every client through the session-less
|
||||
`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
|
||||
then streams live transitions. `AcknowledgeAlarm` is session-less and routes
|
||||
through the monitor. Clients never open a worker session to see alarms, and
|
||||
then streams live transitions. `AcknowledgeAlarm` is session-less, routes
|
||||
through the monitor, and carries the operator's credentials (see
|
||||
"Acknowledge" below). Clients never open a worker session to see alarms, and
|
||||
alarm monitoring is independent of client lifecycle; the monitor re-opens its
|
||||
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
|
||||
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
||||
@@ -221,20 +222,22 @@ forwards only events the worker emits and never synthesizes transitions
|
||||
itself. The synthesis rules are documented in
|
||||
`docs/AlarmClientDiscovery.md`.
|
||||
|
||||
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
|
||||
comment to the alarm attribute's ack-comment subtag. The write performs the
|
||||
ack. If the attribute has no writable ack-comment subtag configured, the RPC
|
||||
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
|
||||
used as before.
|
||||
|
||||
**Acceptance is not application:** the ack is forwarded to the provider, and a
|
||||
successful return means the provider accepted the call, not that the
|
||||
acknowledgement was applied. On galaxies whose alarmed attributes carry a
|
||||
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the
|
||||
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated
|
||||
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack
|
||||
by the resulting transition, never by the return code. See
|
||||
`docs/AlarmProbeFindings.md`.
|
||||
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
|
||||
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
|
||||
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
|
||||
operator (`operator_user`, `operator_password`, optional `operator_domain`).
|
||||
The AVEVA runtime treats that write as the acknowledgement and historizes it
|
||||
with the authenticated account's name, domain and full name; the Historian's
|
||||
OperatorNode is the ack writer's MXAccess client name
|
||||
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
|
||||
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
|
||||
`MxaccessFailure` with the status detail, and no completion as success with a
|
||||
diagnostic noting the write was sent but unconfirmed. An unknown alarm or one
|
||||
with no ack target (non-Galaxy provider, no ack-comment subtag) is reported as
|
||||
`InvalidRequest` with `hresult` -1. Alarm-client ack calls
|
||||
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
|
||||
used: live tests showed a headless consumer's acks are accepted and silently
|
||||
dropped (see `docs/AlarmProbeFindings.md`).
|
||||
|
||||
**Degraded state visibility:** every subtag-mode transition carries
|
||||
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
|
||||
@@ -1028,8 +1031,9 @@ Internal worker IPC:
|
||||
- gateway validates worker executable path and version,
|
||||
- no secrets in command line when avoidable.
|
||||
|
||||
Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured`
|
||||
must not log passwords or raw credential values.
|
||||
Credential-sensitive commands such as `AuthenticateUser`, `WriteSecured` and
|
||||
`AcknowledgeAlarm` (its `operator_password`) must not log passwords or raw
|
||||
credential values.
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public static class GatewayContractInfo
|
||||
/// (<c>WorkerPipeSession.CreateDrainEventsReply</c>, the backstop that also interprets
|
||||
/// <c>max_events = 0</c>) cannot drift apart. A count cap alone is necessary but not
|
||||
/// sufficient: the worker additionally caps the reply by serialized bytes against the
|
||||
/// negotiated frame maximum (WRK-21), so a reply may carry fewer events than this ceiling
|
||||
/// negotiated frame maximum, so a reply may carry fewer events than this ceiling
|
||||
/// and fewer than are queued. Callers drain iteratively until an empty reply.
|
||||
/// This is a documented behavioral bound, not wire schema — it is deliberately a C#
|
||||
/// constant and not a <c>.proto</c> field.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
|
||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
message AcknowledgeAlarmCommand {
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
string alarm_guid = 1;
|
||||
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
|
||||
string operator_node = 4;
|
||||
string operator_domain = 5;
|
||||
string operator_full_name = 6;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 7;
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
message AcknowledgeAlarmByNameCommand {
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
// may contain dots; the gateway-side parser splits on the first dot
|
||||
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
|
||||
string operator_node = 6;
|
||||
string operator_domain = 7;
|
||||
string operator_full_name = 8;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 9;
|
||||
}
|
||||
|
||||
message UnsubscribeBulkCommand {
|
||||
@@ -725,11 +734,11 @@ message DrainEventsReply {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
message AcknowledgeAlarmReplyPayload {
|
||||
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
string operator_user = 5;
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
string operator_password = 6;
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
string operator_domain = 7;
|
||||
}
|
||||
|
||||
message AcknowledgeAlarmReply {
|
||||
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
|
||||
ProtocolStatus protocol_status = 3;
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
optional int32 hresult = 4;
|
||||
|
||||
@@ -22,4 +22,10 @@ public static class WorkerLaunchEnvironment
|
||||
|
||||
/// <summary>Maximum alarm records requested in one current-alarm fetch.</summary>
|
||||
public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess client name the worker's alarm-ack writer registers with. The
|
||||
/// Historian records it as the acknowledgement's OperatorNode.
|
||||
/// </summary>
|
||||
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class DashboardLdapLiveTests
|
||||
private const string SharedDirectoryPassword = "password";
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen
|
||||
/// Dashboard visibility tags used by the ACL scenarios below. They are operator-chosen
|
||||
/// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag
|
||||
/// list — nothing in the shared directory carries them.
|
||||
/// </summary>
|
||||
@@ -168,7 +168,7 @@ public sealed class DashboardLdapLiveTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
|
||||
/// Verifies the tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
|
||||
/// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>,
|
||||
/// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a
|
||||
/// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one.
|
||||
@@ -275,7 +275,7 @@ public sealed class DashboardLdapLiveTests
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
|
||||
/// The tagged fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
|
||||
/// name groups that already exist in the shared directory — the tag layer is config-only.
|
||||
/// </summary>
|
||||
private static DashboardOptions TaggedDashboardOptions() => new()
|
||||
|
||||
@@ -526,7 +526,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
// Assert. But a silent fall-through to "operator only" (currentUserId 0)
|
||||
// quietly demotes the rest of this test from an authenticated round-trip
|
||||
// to a bare failure-reply-shape check, which a stale rig credential (e.g.
|
||||
// TST-31) would otherwise hide indefinitely in nightly runs. Log the
|
||||
// one that has expired) would otherwise hide indefinitely in nightly runs. Log the
|
||||
// outcome loudly — never the password (verifyUser only) — so that
|
||||
// degradation is visible in the test output even though it never fails
|
||||
// the test by itself.
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@
|
||||
(IntegrationTests-028).
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.2.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class AlarmProviderReasons
|
||||
/// <summary>
|
||||
/// Reason prefix set when the effective mode can need the subtag watch-list
|
||||
/// (<c>Auto</c> or <c>ForceSubtag</c>) but the list resolved to zero targets, so the
|
||||
/// subtag provider covers no alarms (GWC-37). In <c>Auto</c> this is a warning about the
|
||||
/// subtag provider covers no alarms. In <c>Auto</c> this is a warning about the
|
||||
/// standby, not about the currently-serving alarm manager, which is why the dashboard
|
||||
/// renders it against the <c>ALARMMGR</c> mode as a coverage badge rather than a failover.
|
||||
/// </summary>
|
||||
@@ -29,7 +29,7 @@ public static class AlarmProviderReasons
|
||||
/// <summary>
|
||||
/// Reason prefix set when Galaxy Repository alarm discovery was enabled but failed, so
|
||||
/// the watch-list carries only what configuration contributed and may be incomplete
|
||||
/// (GWC-37). Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
|
||||
/// . Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
|
||||
/// usable config-only list is a completeness warning, not a total loss of coverage.
|
||||
/// </summary>
|
||||
public const string SubtagDiscoveryFailed =
|
||||
@@ -37,7 +37,7 @@ public static class AlarmProviderReasons
|
||||
|
||||
/// <summary>
|
||||
/// Reason prefix set when a re-resolution at the failover edge produced a different
|
||||
/// watch-list than the one sent at <c>SubscribeAlarms</c> (GWC-37). The worker keeps the
|
||||
/// watch-list than the one sent at <c>SubscribeAlarms</c>. The worker keeps the
|
||||
/// list it was given for the life of the subscription, so the difference is reported
|
||||
/// rather than applied; a monitor restart re-resolves and re-sends.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
/// <summary>
|
||||
/// The outcome of one subtag watch-list resolution: the composed targets plus enough
|
||||
/// provenance for the caller to tell an empty list that means "nothing was configured"
|
||||
/// from one that means "discovery was configured and silently produced nothing"
|
||||
/// (GWC-37). <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
|
||||
/// from one that means "discovery was configured and silently produced nothing".
|
||||
/// <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
|
||||
/// on a discovery failure, so without this the two are indistinguishable at the call site
|
||||
/// and a Galaxy Repository outage at startup can only be found in a log line.
|
||||
/// </summary>
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
|
||||
|
||||
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
|
||||
|
||||
// GWC-37: the caller cannot grade an empty watch-list without knowing whether discovery
|
||||
// The caller cannot grade an empty watch-list without knowing whether discovery
|
||||
// was asked to contribute and whether it failed while doing so, because the failure is
|
||||
// deliberately swallowed below.
|
||||
string? discoveryFailureMessage = null;
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// every path that writes _alarms must null this under _sync, or readers keep a stale set.
|
||||
private ActiveAlarmSnapshot[]? _currentAlarmsProjection;
|
||||
|
||||
// NEXT-03 dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
|
||||
// Dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
|
||||
// the most recent reconcile pass, keyed by reference with the instance's original raise
|
||||
// timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate
|
||||
// of the repair and is suppressed. One generation deep: each reconcile pass replaces the map,
|
||||
@@ -57,7 +57,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
private string _providerReason = string.Empty;
|
||||
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
|
||||
|
||||
// GWC-37 subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
|
||||
// Subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
|
||||
// using can actually cover anything, and why not. Independent of _providerDegraded (which
|
||||
// means "the subtag provider is the one serving"): in Auto mode the alarm manager can be
|
||||
// serving perfectly while the standby behind it covers zero alarms, and that is invisible
|
||||
@@ -258,7 +258,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
try
|
||||
{
|
||||
// Attach the internal distributor subscriber BEFORE subscribing (GWC-26). The pump
|
||||
// Attach the internal distributor subscriber BEFORE subscribing. The pump
|
||||
// has been running since MarkReady started the dashboard mirror, and the distributor
|
||||
// only fans to subscribers registered at the time of the fan-out, so a subscriber
|
||||
// taken after SubscribeAlarms + the first reconcile would silently lose every
|
||||
@@ -351,7 +351,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
|
||||
|
||||
// GWC-37: grade the coverage before the command goes out, so a Galaxy Repository outage at
|
||||
// Grade the coverage before the command goes out, so a Galaxy Repository outage at
|
||||
// startup is a standing status on the feed instead of one log line an operator reads weeks
|
||||
// later — when the failover that needed the watch-list finds it empty. Broadcast because
|
||||
// feed subscribers outlive the monitor's worker session and may already be attached.
|
||||
@@ -507,7 +507,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
}
|
||||
}
|
||||
|
||||
// NEXT-03: best-effort dedup of the reconcile/live race. A reconcile that already synthesized
|
||||
// Best-effort dedup of the reconcile/live race. A reconcile that already synthesized
|
||||
// this transition as a feed repair left the cache carrying the worker's transition timestamp
|
||||
// and resulting state — both derived from the same worker-side value the live transition
|
||||
// carries — so an exact (timestamp, state) match means this live transition's outcome has
|
||||
@@ -521,7 +521,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
&& existing.CurrentState == incoming.CurrentState;
|
||||
}
|
||||
|
||||
// NEXT-03, the Clear leg. A reconcile Clear repair removes the cache entry before the buffered
|
||||
// The Clear leg of the dedup. A reconcile Clear repair removes the cache entry before the buffered
|
||||
// live Clear drains, so there is no cached state to compare against; the tombstone recorded by
|
||||
// ApplyReconcile identifies the cleared instance by its original raise timestamp instead. The
|
||||
// match consumes the tombstone, so a genuinely new raise/clear cycle (which carries a newer
|
||||
@@ -803,7 +803,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
|
||||
//
|
||||
// GWC-37: degraded is the OR of "the subtag provider is the one serving" and "the subtag
|
||||
// Degraded is the OR of "the subtag provider is the one serving" and "the subtag
|
||||
// watch-list cannot cover anything". The second is what makes an empty watch-list visible in
|
||||
// Auto mode, where the alarm manager is serving happily and the standby behind it is empty.
|
||||
// The reasons are concatenated when both apply, which is the worst case worth naming loudly:
|
||||
@@ -857,7 +857,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||
// a synthetic transition for any alarm the live stream missed.
|
||||
//
|
||||
// Repair scope (GWC-26): presence deltas (Clear/Raise) plus acked-state deltas. These are
|
||||
// Repair scope: presence deltas (Clear/Raise) plus acked-state deltas. These are
|
||||
// ALARM FEED transitions (AlarmFeedMessage on the StreamAlarms/dashboard surface), rebuilt
|
||||
// from the worker's own authoritative snapshot to repair what the live feed missed. They are
|
||||
// not MxEvents and never reach the gRPC StreamEvents path, so this feed-level repair does not
|
||||
@@ -867,7 +867,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
// reads the worker's current state while the corresponding live transition may still be
|
||||
// buffered in the alarm lease's channel; both would then broadcast, and the two are
|
||||
// indistinguishable on the feed, since nothing serializes a reconcile against the in-flight
|
||||
// live stream. ApplyTransition narrows that window with a best-effort dedup (NEXT-03): a live
|
||||
// live stream. ApplyTransition narrows that window with a best-effort dedup: a live
|
||||
// transition whose worker timestamp and resulting state the cache already carries — or whose
|
||||
// Clear matches a tombstone recorded below — was already broadcast as a repair and is
|
||||
// suppressed. The dedup fires only on a positive marker match, so the contract stays
|
||||
@@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
{
|
||||
parseError = null;
|
||||
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
||||
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
string comment = request.Comment ?? string.Empty;
|
||||
string operatorUser = request.OperatorUser ?? string.Empty;
|
||||
string operatorPassword = request.OperatorPassword ?? string.Empty;
|
||||
string operatorDomain = request.OperatorDomain ?? string.Empty;
|
||||
|
||||
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||
{
|
||||
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorDomain = operatorDomain,
|
||||
OperatorPassword = operatorPassword,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorDomain = operatorDomain,
|
||||
OperatorPassword = operatorPassword,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
|
||||
/// </summary>
|
||||
public int MaxAlarmsPerFetch { get; init; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess client name the alarm worker's ack writer registers with. The
|
||||
/// AVEVA Historian records it as each acknowledgement's OperatorNode.
|
||||
/// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
|
||||
/// </summary>
|
||||
public string AckClientName { get; init; } = "MxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
|
||||
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
|
||||
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
|
||||
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a
|
||||
/// rooted path on the running host for the same reason the auth DB path must be: a
|
||||
/// non-rooted value silently resolves against the launch working directory.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
|
||||
|
||||
@@ -18,7 +18,7 @@ internal static class GatewayConfigPathRules
|
||||
/// against the launch working directory, so the store moves with the CWD and can leak into the
|
||||
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
|
||||
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
|
||||
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject
|
||||
/// blessed and then written as a junk-named relative file. Reject
|
||||
/// rather than auto-root; silent relocation of a credential store is worse than a boot error.
|
||||
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
|
||||
/// </summary>
|
||||
|
||||
@@ -113,7 +113,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
"MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
|
||||
builder);
|
||||
|
||||
// The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate
|
||||
// The two-layer limiter knobs accept 0 as "disable this layer": a zero aggregate
|
||||
// limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
|
||||
// Negatives express no intent.
|
||||
AddIfNegative(
|
||||
@@ -341,7 +341,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
}
|
||||
|
||||
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
|
||||
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
|
||||
// sized checked(2 * EventChannelCapacity). Cap it at int.MaxValue/2 so that doubling
|
||||
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
|
||||
// upper-bound pattern.
|
||||
private const int MaximumEventQueueCapacity = int.MaxValue / 2;
|
||||
@@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
$"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.AckClientName)
|
||||
|| options.AckClientName.Length > 64
|
||||
|| options.AckClientName.Any(char.IsControl))
|
||||
{
|
||||
builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters.");
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
@@ -607,7 +614,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
// at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
|
||||
// message is not doubled up with the individual range errors.
|
||||
//
|
||||
// IPC-33: the coupling also has an upper half. The worker budgets a DrainEvents reply against the
|
||||
// The coupling also has an upper half. The worker budgets a DrainEvents reply against the
|
||||
// negotiated frame maximum less two reserves, and that same MxCommandReply is then returned
|
||||
// verbatim through Invoke, where the gateway's gRPC MaxSendMessageSize (= MaxGrpcMessageBytes)
|
||||
// applies. Letting the pipe maximum run arbitrarily far above the gRPC cap lets the worker pack a
|
||||
@@ -671,7 +678,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
|
||||
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
|
||||
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
|
||||
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
|
||||
// fails fast instead of being blessed and written as a junk-named relative file.
|
||||
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
|
||||
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class WorkerOptions
|
||||
/// still fits one worker frame, and at most <em>twice</em> that reserve above it so a
|
||||
/// <c>DrainEvents</c> reply budgeted against this maximum (frame max less two reserves) still
|
||||
/// fits the gRPC send cap it is returned through — otherwise the worker can pack a reply the
|
||||
/// pipe carries and the gRPC layer refuses, losing the already-drained events (IPC-33). The
|
||||
/// pipe carries and the gRPC layer refuses, losing the already-drained events. The
|
||||
/// gateway conveys this value to the worker in the handshake
|
||||
/// (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap plus one reserve.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>
|
||||
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller
|
||||
/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which
|
||||
/// is the point — there is no configuration value that can turn this into a bypass (SEC-40).
|
||||
/// is the point — there is no configuration value that can turn this into a bypass.
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedDashboardRoleHandler
|
||||
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// into this one by accident.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
|
||||
/// Introduced to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
|
||||
/// which is gateway-central and session-less and therefore carries no per-session provenance the
|
||||
/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
|
||||
/// tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
|
||||
/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path),
|
||||
/// state, severity, category, area, acknowledging operator — away from the read-only environment
|
||||
/// bypasses, which exist for local development and unauthenticated deployments rather than as a
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed record DashboardAlarmProviderStatus(
|
||||
|
||||
/// <summary>
|
||||
/// Badge label shown when the alarm manager is serving normally but the subtag standby
|
||||
/// behind it covers nothing — an empty or undiscoverable watch-list (GWC-37). The feed is
|
||||
/// behind it covers nothing — an empty or undiscoverable watch-list. The feed is
|
||||
/// healthy today; what is degraded is the fallback that would take over tomorrow, so the
|
||||
/// badge names the standby rather than claiming the feed has failed over.
|
||||
/// </summary>
|
||||
@@ -77,7 +77,7 @@ public sealed record DashboardAlarmProviderStatus(
|
||||
&& status.Mode == AlarmProviderMode.Subtag
|
||||
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
|
||||
|
||||
// GWC-37: degraded WITHOUT subtag mode means the alarm manager is still serving and the
|
||||
// Degraded WITHOUT subtag mode means the alarm manager is still serving and the
|
||||
// standby is the unsound half. "Subtag monitoring (degraded)" would misreport which
|
||||
// provider is live, so this state gets its own label; the amber badge is shared because
|
||||
// the operator action — fix discovery — is equally overdue in both.
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class DashboardAuthenticationDefaults
|
||||
|
||||
/// <summary>
|
||||
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
|
||||
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the
|
||||
/// <c>/alarms</c> page that reads the same central monitor in process. Accepts the
|
||||
/// same two schemes as <see cref="HubClientsPolicy"/> but carries
|
||||
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
|
||||
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
|
||||
@@ -49,10 +49,10 @@ public static class DashboardAuthenticationDefaults
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
|
||||
/// <summary>
|
||||
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
|
||||
/// Claim carrying one dashboard event-visibility tag the caller is granted. Stamped
|
||||
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
|
||||
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
|
||||
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads
|
||||
/// <c>MxGateway:Dashboard:GroupToTag</c>. <see cref="IDashboardSessionAcl"/> reads
|
||||
/// this claim only as a FALLBACK, for a principal that carries no
|
||||
/// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose
|
||||
/// payload carries tags but not groups); a cookie principal is re-resolved against current
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
|
||||
/// <param name="options">
|
||||
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
|
||||
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
|
||||
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal.
|
||||
/// </param>
|
||||
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||
public sealed class DashboardAuthenticator(
|
||||
@@ -142,7 +142,7 @@ public sealed class DashboardAuthenticator(
|
||||
claims.AddRange(groupNames.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
group)));
|
||||
// Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
|
||||
// Dashboard event-visibility tags. Visibility only — never a data-access grant —
|
||||
// and never logged: only the decision, never the tag values, reaches diagnostics.
|
||||
claims.AddRange(DashboardGroupTagMapping
|
||||
.MapGroupsToTags(groupNames, groupToTag)
|
||||
|
||||
@@ -107,7 +107,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
|
||||
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
|
||||
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
|
||||
// real authenticated dashboard role (SEC-40) — see AlarmsHub for why the alarm feed is
|
||||
// real authenticated dashboard role — see AlarmsHub for why the alarm feed is
|
||||
// gated harder than the session-scoped ones.
|
||||
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
||||
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
||||
|
||||
@@ -177,7 +177,7 @@ public static class DashboardServiceCollectionExtensions
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
|
||||
// SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
|
||||
// Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
|
||||
// and an external hub reader), but a requirement type DashboardAuthorizationHandler
|
||||
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
|
||||
// can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role.
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// (ordinal-ignore-case).</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <strong>Where the grant comes from (SEC-41).</strong> The grant is re-resolved on every call
|
||||
/// <strong>Where the grant comes from.</strong> The grant is re-resolved on every call
|
||||
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
|
||||
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
|
||||
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
|
||||
@@ -120,7 +120,7 @@ public sealed class DashboardSessionAcl(
|
||||
|
||||
if (groups is not null)
|
||||
{
|
||||
// SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an
|
||||
// Current options, not the login-time snapshot. MapGroupsToTags returns an
|
||||
// OrdinalIgnoreCase set, so probing it with the session's tags carries the
|
||||
// case-insensitive comparison in the same direction the claim scan below does.
|
||||
IReadOnlySet<string> granted = DashboardGroupTagMapping.MapGroupsToTags(
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class HubTokenService
|
||||
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
||||
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
|
||||
// bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
|
||||
// the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at
|
||||
// the same way: the token carries the tags resolved from the caller's LDAP groups at
|
||||
// mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
|
||||
// connections within one lifetime — the natural place the deferred "tokens gain session
|
||||
// binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no
|
||||
/// session provenance, so the SEC-25 per-session tag ACL that scopes <c>EventsHub</c> has nothing
|
||||
/// session provenance, so the per-session tag ACL that scopes <c>EventsHub</c> has nothing
|
||||
/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole
|
||||
/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate:
|
||||
/// subscription expression's alarm metadata. What is tightened instead is the entry gate:
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
|
||||
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
|
||||
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
|
||||
/// those audiences is subject to this dashboard-display flag.
|
||||
/// <para>
|
||||
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub
|
||||
/// The broadcast stays deliberately unfiltered. Audience is decided once, at hub
|
||||
/// admission — <see cref="AlarmsHub"/> carries
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
|
||||
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm
|
||||
|
||||
@@ -39,8 +39,8 @@ public sealed class EventsHub(
|
||||
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
|
||||
/// only checks that the caller carries one of the dashboard roles, which by
|
||||
/// itself would let any Viewer subscribe to any session id they name. The
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
|
||||
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s.
|
||||
/// The admin bypass is evaluated first, so an
|
||||
/// Administrator joins any session id they name; every check below it
|
||||
/// applies to non-Admin callers only. For those: a Viewer sees a session
|
||||
/// only when its tags intersect their granted tags, an untagged session
|
||||
@@ -95,16 +95,12 @@ public sealed class EventsHub(
|
||||
viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases every session subscription the dropped connection held. A browser
|
||||
/// tab that closes never calls <see cref="UnsubscribeSession"/>, so without
|
||||
/// this the session would look watched forever and the mirror would keep
|
||||
/// cloning and sending events to an empty group.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception that terminated the connection, if any.</param>
|
||||
/// <returns>A task representing the disconnect handling.</returns>
|
||||
/// <inheritdoc />
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
// Release every session subscription the dropped connection held. A browser tab that
|
||||
// closes never calls UnsubscribeSession, so without this the session would look watched
|
||||
// forever and the mirror would keep cloning and sending events to an empty group.
|
||||
viewerRegistry.ReleaseConnection(Context.ConnectionId);
|
||||
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a dashboard principal may observe one session's mirrored
|
||||
/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the
|
||||
/// event stream. Consulted at every subscribe seam: the
|
||||
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
|
||||
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
|
||||
/// session-details page.
|
||||
|
||||
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
"WriteSecured2",
|
||||
// Carry operator_password.
|
||||
"AcknowledgeAlarm",
|
||||
"AcknowledgeAlarmByName"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
|
||||
}
|
||||
|
||||
RedactCommandValue(properties);
|
||||
|
||||
foreach (string key in properties.Keys.ToList())
|
||||
{
|
||||
if (key.Contains("password", StringComparison.OrdinalIgnoreCase) && properties[key] is not null)
|
||||
{
|
||||
properties[key] = GatewayLogRedactor.RedactedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -154,8 +154,8 @@ public static class GatewayApplication
|
||||
// having pushed the ambient identity before the library's galaxy service runs. That
|
||||
// ordering is still required, but it is no longer trusted silently: with
|
||||
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
|
||||
// invariant when the identity is absent, instead of resolving to the full hierarchy
|
||||
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
// invariant when the identity is absent, instead of resolving to the full hierarchy.
|
||||
// Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||
|
||||
@@ -164,7 +164,7 @@ public static class GatewayApplication
|
||||
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
|
||||
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
|
||||
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
|
||||
// byte-identical to the removed appsettings literal on Windows (SEC-33).
|
||||
// byte-identical to the removed appsettings literal on Windows.
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
|
||||
{
|
||||
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
|
||||
@@ -175,7 +175,7 @@ public static class GatewayApplication
|
||||
|
||||
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
|
||||
|
||||
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33).
|
||||
// Validate that persistence has a valid, host-rooted snapshot path.
|
||||
builder.Services.AddSingleton<
|
||||
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
|
||||
Configuration.GalaxyRepositoryOptionsValidator>();
|
||||
@@ -224,7 +224,7 @@ public static class GatewayApplication
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
|
||||
/// together, and the mechanism is the one SEC-33 already used for
|
||||
/// together, and the mechanism is the same mechanism already used for
|
||||
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
|
||||
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
|
||||
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
|
||||
|
||||
@@ -116,7 +116,7 @@ public sealed class EventStreamService(
|
||||
// its `yield return` is what feeds the gRPC writer.
|
||||
ChannelReader<MxEvent> reader = subscriber.Reader;
|
||||
|
||||
// GWC-15: register this subscriber's channel as a live backlog source instead of
|
||||
// Register this subscriber's channel as a live backlog source instead of
|
||||
// reconciling the queue-depth gauge on every event. The gauge previously read the
|
||||
// bounded channel's Count (which takes the channel's internal lock) and adjusted the
|
||||
// metric under its own lock on every streamed event. Now the metric reads Count only
|
||||
@@ -197,7 +197,7 @@ public sealed class EventStreamService(
|
||||
}
|
||||
|
||||
// The queue-depth gauge is maintained lazily via the backlog registration
|
||||
// above (GWC-15): the metric reads this subscriber's channel Count only when
|
||||
// above: the metric reads this subscriber's channel Count only when
|
||||
// scraped, so there is no per-event gauge bookkeeping on this hot path.
|
||||
yield return mxEvent;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
// The session's owner id and its dashboard-visibility tags both come from the resolved
|
||||
// API key identity, never from the request: the key is the tenant principal, so a
|
||||
// client cannot label its own session with another tenant's tag (SEC-25).
|
||||
// client cannot label its own session with another tenant's tag.
|
||||
ApiKeyIdentity? owner = identityAccessor.Current;
|
||||
GatewaySession session = await sessionManager
|
||||
.OpenSessionAsync(
|
||||
@@ -130,7 +130,7 @@ public sealed class MxAccessGatewayService(
|
||||
|
||||
// Map from the command alone: cloning the whole request only to overwrite its command with
|
||||
// commandToInvoke deep-cloned the (potentially large) original payload for nothing, since
|
||||
// MapCommand reads nothing but the command (GWC-29). The one clone that matters still
|
||||
// MapCommand reads nothing but the command. The one clone that matters still
|
||||
// happens inside MapCommand, which is what keeps the worker-bound graph unaliased from
|
||||
// commandToInvoke — the caller still reads it below via TrackCommandReply.
|
||||
WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke);
|
||||
@@ -167,7 +167,7 @@ public sealed class MxAccessGatewayService(
|
||||
.WithCancellation(context.CancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// GWC-06: measure send latency with the allocation-free timestamp API rather
|
||||
// Measure send latency with the allocation-free timestamp API rather
|
||||
// than allocating a Stopwatch object per event per subscriber on the highest-
|
||||
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
|
||||
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
|
||||
@@ -185,15 +185,6 @@ public sealed class MxAccessGatewayService(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
|
||||
/// session-less: the gateway routes it through the always-on
|
||||
/// <see cref="IGatewayAlarmService"/> monitor session. An
|
||||
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
|
||||
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
|
||||
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
|
||||
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
|
||||
/// </remarks>
|
||||
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
||||
AcknowledgeAlarmRequest request,
|
||||
ServerCallContext context)
|
||||
@@ -206,6 +197,11 @@ public sealed class MxAccessGatewayService(
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.OperatorUser))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
|
||||
}
|
||||
|
||||
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class MxAccessGrpcMapper
|
||||
/// <summary>
|
||||
/// Maps a gRPC MX command to a worker command. Callers that already hold the command — including
|
||||
/// the constraint pipeline, whose rewritten command is not the one on the request — use this
|
||||
/// overload rather than cloning a whole request to carry a single field (GWC-29); nothing outside
|
||||
/// overload rather than cloning a whole request to carry a single field; nothing outside
|
||||
/// the command is read here.
|
||||
/// </summary>
|
||||
/// <param name="command">Command payload.</param>
|
||||
@@ -71,7 +71,7 @@ public sealed class MxAccessGrpcMapper
|
||||
};
|
||||
}
|
||||
|
||||
// GWC-07 / IPC-05: ownership transfer, not a deep clone — the same rule MapEvent follows,
|
||||
// ownership transfer, not a deep clone — the same rule MapEvent follows,
|
||||
// applied to the other (and larger, on bulk reads) hot-path message. The enclosing
|
||||
// WorkerCommandReply is parsed fresh from a single pipe frame in WorkerClient's read loop
|
||||
// and is single-consumer by construction: CompleteCommand's TryRemove hands it to exactly
|
||||
@@ -93,10 +93,10 @@ public sealed class MxAccessGrpcMapper
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workerEvent);
|
||||
|
||||
// GWC-07 / IPC-05: ownership transfer, not a deep clone. The enclosing WorkerEvent is
|
||||
// ownership transfer, not a deep clone. The enclosing WorkerEvent is
|
||||
// parsed fresh from a single pipe frame in WorkerClient's read loop and is discarded
|
||||
// immediately after this mapping — the SessionEventDistributor pump is its single
|
||||
// consumer (GWC-01 claims the worker event channel as single-reader), so nothing else
|
||||
// consumer (it claims the worker event channel as single-reader), so nothing else
|
||||
// aliases or mutates workerEvent.Event. We therefore move the inner MxEvent into the
|
||||
// outbound graph instead of cloning it. Downstream the pump fans this one MxEvent to
|
||||
// every subscriber and retains it in the replay ring, but that sharing is READ-ONLY
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly ConcurrentDictionary<string, long> _eventsBySession = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> _retryAttemptsByArea = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// GWC-15: live backlog sources for the gRPC event-stream queue-depth gauge. Each active
|
||||
// live backlog sources for the gRPC event-stream queue-depth gauge. Each active
|
||||
// StreamEvents subscriber registers a delegate returning its channel's current backlog; the
|
||||
// gauge sums them ONLY when scraped or projected (GetGrpcEventStreamQueueDepth), so nothing is
|
||||
// read on the per-event streaming hot path. Concurrent because subscribers register/unregister
|
||||
@@ -43,7 +43,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new();
|
||||
private long _nextEventStreamBacklogSourceId;
|
||||
|
||||
// PERF-02: the same pull model for the worker event queue depth. It replaces a pushed scalar that
|
||||
// the same pull model for the worker event queue depth. It replaces a pushed scalar that
|
||||
// every WorkerClient wrote twice per event (staged, consumed) under _syncRoot — a process-wide
|
||||
// lock on the hottest path, and last-writer-wins across sessions, so the gauge reported one
|
||||
// arbitrary session's backlog instead of the gateway's. Each client registers a source returning
|
||||
@@ -210,7 +210,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// <param name="method">Name of the command method.</param>
|
||||
public void CommandStarted(string method)
|
||||
{
|
||||
// PERF-02: the three command counters run two-to-three times per gRPC call, so they use
|
||||
// the three command counters run two-to-three times per gRPC call, so they use
|
||||
// Interlocked rather than _syncRoot — the same idiom as EventReceived. Nothing here needs a
|
||||
// consistent multi-field view; GetSnapshot reads each with Interlocked.Read.
|
||||
Interlocked.Increment(ref _commandsStarted);
|
||||
@@ -281,7 +281,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// Registers a live depth source for the worker event queue-depth gauge and returns a handle
|
||||
/// that removes it when disposed. Each <c>WorkerClient</c> registers once and reports its own
|
||||
/// undelivered (staged + queued) event count, so the gauge is the gateway-wide sum instead of
|
||||
/// the last value any one session happened to push (PERF-02).
|
||||
/// the last value any one session happened to push.
|
||||
/// </summary>
|
||||
/// <param name="depth">
|
||||
/// Returns this worker client's current undelivered event count. Invoked only at collection
|
||||
@@ -303,7 +303,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
/// returns a handle that removes it when disposed. The gauge sums every registered
|
||||
/// source's current value on demand (when scraped or via <see cref="GetSnapshot"/>),
|
||||
/// rather than tracking a pushed running total, so the streaming hot path does no
|
||||
/// per-event gauge bookkeeping (GWC-15).
|
||||
/// per-event gauge bookkeeping.
|
||||
/// </summary>
|
||||
/// <param name="backlog">
|
||||
/// Returns this subscriber's current channel backlog. Invoked only at collection time;
|
||||
@@ -351,7 +351,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
// Tagged by stage only. Neither the key id nor the peer address may become a tag: key ids
|
||||
// are credential material and peer addresses are unbounded cardinality, and /metrics is not
|
||||
// authenticated (open SEC-14), so anything tagged here is world-readable.
|
||||
// authenticated, so anything tagged here is world-readable.
|
||||
_authThrottledCounter.Add(1, new KeyValuePair<string, object?>("stage", stage));
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
// Compute the live queue depths outside _syncRoot: the sources are the subscriber channels'
|
||||
// Count (their own locks) and the worker clients' interlocked counters, neither of which may
|
||||
// run under this lock. GWC-15, PERF-02.
|
||||
// run under this lock.
|
||||
int workerEventQueueDepth = GetWorkerEventQueueDepth();
|
||||
int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth();
|
||||
lock (_syncRoot)
|
||||
@@ -521,7 +521,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Sums the undelivered event backlog across every live worker client (PERF-02).
|
||||
// Sums the undelivered event backlog across every live worker client.
|
||||
private int GetWorkerEventQueueDepth() => SumSources(_workerEventQueueDepthSources);
|
||||
|
||||
// Sums the live backlog across every registered event-stream subscriber.
|
||||
@@ -571,6 +571,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Removes the source from its gauge; subsequent calls are no-ops.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
|
||||
@@ -39,12 +39,7 @@ public sealed class AuditDrainService(
|
||||
/// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary>
|
||||
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the
|
||||
/// writer switches from synchronous write-through to enqueueing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -64,12 +59,7 @@ public sealed class AuditDrainService(
|
||||
await base.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detaches the drain (so late writes go straight to the sink) and gives the buffered
|
||||
/// events a bounded window to reach the store.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
writer.DetachDrain();
|
||||
|
||||
@@ -87,11 +87,9 @@ public sealed class SqliteCanonicalAuditStore(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// One connection, one transaction and one prepared command for the whole batch: the drain
|
||||
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather
|
||||
/// than one round-trip per event.
|
||||
/// </remarks>
|
||||
// One connection, one transaction and one prepared command for the whole batch: the drain
|
||||
// pays a single commit for up to AuditDrainService.MaxBatchSize events rather
|
||||
// than one round-trip per event.
|
||||
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(auditEvents);
|
||||
@@ -148,17 +146,15 @@ public sealed class SqliteCanonicalAuditStore(
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The comparison goes through SQLite's <c>datetime()</c> rather than comparing the stored
|
||||
/// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
|
||||
/// ISO-8601 — which <see cref="AuditEvent.OccurredAtUtc"/> guarantees for rows written through
|
||||
/// this store, but not for rows that entered the table any other way (a repair script, an
|
||||
/// older schema, a future producer). On a mixed-format column a text comparison silently
|
||||
/// deletes live audit: <c>2026-05-17T09:00:00-05:00</c> is two hours AFTER a
|
||||
/// <c>2026-05-17T12:00:00+00:00</c> cutoff yet sorts before it. Comparing instants is correct
|
||||
/// regardless of how the text got there, and anything <c>datetime()</c> cannot parse yields
|
||||
/// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
|
||||
/// </remarks>
|
||||
// The comparison goes through SQLite's datetime() rather than comparing the stored
|
||||
// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
|
||||
// ISO-8601 — which AuditEvent.OccurredAtUtc guarantees for rows written through
|
||||
// this store, but not for rows that entered the table any other way (a repair script, an
|
||||
// older schema, a future producer). On a mixed-format column a text comparison silently
|
||||
// deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a
|
||||
// 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct
|
||||
// regardless of how the text got there, and anything datetime() cannot parse yields
|
||||
// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
|
||||
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed record ApiKeyConstraints(
|
||||
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
|
||||
/// Gets the dashboard event-visibility tags granted to this key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -44,7 +44,7 @@ public interface IApiKeyCacheInvalidator
|
||||
/// evicting so an in-flight verification that started under the old generation discards its own
|
||||
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
|
||||
/// take effect immediately" contract true even against a verify that was already in the inner library
|
||||
/// when the revoke landed (SEC-34 window 3).
|
||||
/// when the revoke landed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
|
||||
@@ -141,7 +141,7 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
}
|
||||
|
||||
// Caches a successful verification unless the key was invalidated while the inner verify was in
|
||||
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the
|
||||
// flight (detected via the generation snapshot). The entry lifetime stays the
|
||||
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
|
||||
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
|
||||
private void TryCacheSuccess(
|
||||
@@ -202,11 +202,14 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
|
||||
? generation
|
||||
: 0;
|
||||
|
||||
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
|
||||
// the same split the authorization interceptor does. Returns null for a header this cache cannot
|
||||
// attribute to a key id (in which case the generation race-guard is simply not applied).
|
||||
//
|
||||
// Correctness of the SEC-34 generation guard rests on parts[1] being the FULL key id: the '_'
|
||||
/// <summary>
|
||||
/// Parses the key id out of a <c>Bearer mxgw_<keyId>_<secret></c> header without any store access,
|
||||
/// using the same split the authorization interceptor does.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">The raw authorization header value, or <see langword="null"/>.</param>
|
||||
/// <returns>The full key id, or <see langword="null"/> for a header this cache cannot attribute to a key id (the generation race-guard is then not applied).</returns>
|
||||
// Implementation note:
|
||||
// Correctness of the generation guard rests on parts[1] being the FULL key id: the '_'
|
||||
// split would truncate a key id that itself contained '_', silently disarming the guard for that
|
||||
// key. This is safe because '_' is the token's field delimiter and both — and the only — key
|
||||
// creation paths in the gateway forbid it: ApiKeyAdminCommandLineParser.IsValidKeyId and
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
/// source-rotating sprayer that never trips any single partition.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An over-limit state is a valve, not a wall (SEC-31): at most one request per
|
||||
/// An over-limit state is a valve, not a wall: at most one request per
|
||||
/// <see cref="SecurityOptions.ApiKeyFailureProbeIntervalSeconds"/> is admitted through to the real
|
||||
/// verifier, and a successful verification resets both layers. The reset path therefore stays
|
||||
/// reachable while throttled — the earlier design keyed solely on the attacker-supplied key id and
|
||||
@@ -29,8 +29,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
/// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>).
|
||||
/// Because only a validly shaped token mints a key-id partition and each transport peer may mint at
|
||||
/// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the peer-layer overflow collapses onto that
|
||||
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush
|
||||
/// (SEC-32). A validly shaped presented key id still feeds its cross-peer aggregate when its peer
|
||||
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush.
|
||||
/// A validly shaped presented key id still feeds its cross-peer aggregate when its peer
|
||||
/// layer uses the fallback, so exhausting the per-peer cap cannot bypass distributed-spray
|
||||
/// protection; the aggregate map remains bounded by the same capped eviction policy:
|
||||
/// eviction prefers fully expired windows, never removes an over-limit partition below a transient
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
|
||||
/// <remarks>
|
||||
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no
|
||||
/// Distinguishes two states the earlier code collapsed into one: "authenticated, no
|
||||
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
|
||||
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
|
||||
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
|
||||
|
||||
+7
-2
@@ -140,12 +140,17 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
// Token shape mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment (the
|
||||
// secret may itself contain underscores, which does not affect the key id). The shape is checked
|
||||
// before a key-id partition is minted so a spray of invented tokens cannot mint one tracked
|
||||
// partition each and flush the limiter's bounded map (SEC-32). Anything that fails the check
|
||||
// partition each and flush the limiter's bounded map. Anything that fails the check
|
||||
// falls back to the sender's transport-peer partition.
|
||||
//
|
||||
// Internal rather than private so the parse rules can be pinned directly by test; the shape
|
||||
// check is a security boundary (SEC-32) and is worth asserting without routing every case
|
||||
// check is a security boundary and is worth asserting without routing every case
|
||||
// through a full RPC.
|
||||
/// <summary>
|
||||
/// Extracts the API key id from a bearer authorization header.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">The raw <c>authorization</c> header value, or <see langword="null"/>.</param>
|
||||
/// <returns>The key id when the header has the <c>mxgw_<keyId>_<secret></c> shape; otherwise <see langword="null"/>.</returns>
|
||||
internal static string? TryResolveKeyId(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class GatewaySession
|
||||
private bool _everHadEventSubscriber;
|
||||
private SessionEventDistributor? _eventDistributor;
|
||||
private bool _eventDistributorStarted;
|
||||
// GWC-34: set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
|
||||
// Set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
|
||||
// "one distributor per session, never reset" lifecycle symmetric at both ends. Without it
|
||||
// EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT
|
||||
// distributor after teardown — one whose pump can never start (_eventDistributorStarted is a
|
||||
@@ -163,7 +163,7 @@ public sealed class GatewaySession
|
||||
/// is unit-testable.
|
||||
/// </param>
|
||||
/// <param name="ownerDashboardTags">
|
||||
/// Dashboard event-visibility tags inherited from the owning API key (SEC-25). Copied into
|
||||
/// Dashboard event-visibility tags inherited from the owning API key. Copied into
|
||||
/// the immutable <see cref="Tags"/> set; <see langword="null"/> or empty means untagged.
|
||||
/// The tags come from the owner key, never from the client's wire request, so a client
|
||||
/// cannot label its own session with another tenant's tag.
|
||||
@@ -265,8 +265,8 @@ public sealed class GatewaySession
|
||||
public string? OwnerKeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags this session inherited from its owning API key
|
||||
/// (SEC-25). An empty set means untagged.
|
||||
/// Gets the dashboard event-visibility tags this session inherited from its owning API key.
|
||||
/// An empty set means untagged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Immutable for the session's life — assigned once at construction from the owner key's
|
||||
@@ -541,7 +541,7 @@ public sealed class GatewaySession
|
||||
// concurrent callers (e.g. MarkReady's dashboard mirror and a racing first
|
||||
// AttachEventSubscriber) agree on a single distributor and a single start.
|
||||
//
|
||||
// GWC-34: after DisposeAsync the null distributor means "gone", not "not built yet", so this
|
||||
// After DisposeAsync the null distributor means "gone", not "not built yet", so this
|
||||
// throws instead of constructing a replacement nothing will ever pump. The attach paths already
|
||||
// roll back and surface the exception, which is the same SessionNotReady the readiness gates
|
||||
// raise — a caller that loses the race to teardown now fails immediately instead of hanging.
|
||||
@@ -610,7 +610,7 @@ public sealed class GatewaySession
|
||||
/// </exception>
|
||||
public IEventSubscriberLease AttachInternalEventSubscriber()
|
||||
{
|
||||
// Readiness gate, mirroring AttachEventSubscriber (GWC-27). It must run BEFORE
|
||||
// Readiness gate, mirroring AttachEventSubscriber. It must run BEFORE
|
||||
// EnsureDistributorCreated: a premature attach would construct the distributor and start
|
||||
// its pump against a not-yet-Ready worker, the pump source would throw SessionNotReady,
|
||||
// PumpAsync would complete every subscriber with that error and latch _completed, and
|
||||
@@ -694,7 +694,7 @@ public sealed class GatewaySession
|
||||
// These three calls are OUTSIDE _syncRoot to avoid holding it across
|
||||
// EnsureDistributorCreated's own lock and StartAsync's Task.Run.
|
||||
//
|
||||
// GWC-34: the state check above can be overtaken by a close+dispose before we get here, and
|
||||
// The state check above can be overtaken by a close+dispose before we get here, and
|
||||
// EnsureDistributorCreated now says so instead of building an orphan distributor. The
|
||||
// dashboard mirror is a best-effort observability attachment on a session that is already
|
||||
// gone, so it is abandoned here rather than propagated out of MarkReady — the mirror-started
|
||||
@@ -1918,7 +1918,7 @@ public sealed class GatewaySession
|
||||
{
|
||||
distributor = _eventDistributor;
|
||||
_eventDistributor = null;
|
||||
// GWC-34: latch the null-out so EnsureDistributorCreated cannot read it as
|
||||
// Latch the null-out so EnsureDistributorCreated cannot read it as
|
||||
// "not created yet" and hand a racing attach a distributor that will never pump.
|
||||
_eventDistributorDisposed = true;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public interface ISessionManager
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
|
||||
/// tags onto it (SEC-25).
|
||||
/// tags onto it.
|
||||
/// </summary>
|
||||
/// <param name="request">Request payload.</param>
|
||||
/// <param name="clientIdentity">Client identity string.</param>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user