Compare commits
7 Commits
016bdf9c3c
...
632d44f38c
| Author | SHA1 | Date | |
|---|---|---|---|
| 632d44f38c | |||
| 49fb85e92e | |||
| 30ebbdd183 | |||
| a702cb96a8 | |||
| 57679d49f2 | |||
| da955042aa | |||
| 6563511b5f |
@@ -402,18 +402,20 @@ Add an `IConfiguration` parameter (or a configure callback) to
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
Resolved 2026-05-16 (commit pending): added an
|
Resolved 2026-05-16 (commit pending): `AddDeploymentManager()` now calls
|
||||||
`AddDeploymentManager(IServiceCollection, IConfiguration)` overload that binds
|
`services.AddOptions<DeploymentManagerOptions>()` so `IOptions<DeploymentManagerOptions>`
|
||||||
`DeploymentManagerOptions` to the `ScadaLink:DeploymentManager` configuration
|
is always resolvable, and `Host/Program.cs` binds the
|
||||||
section (exposed as `ServiceCollectionExtensions.OptionsSection`). The
|
`ScadaLink:DeploymentManager` section (exposed as
|
||||||
`Microsoft.Extensions.Options.ConfigurationExtensions` package was added to the
|
`ServiceCollectionExtensions.OptionsSection`) via
|
||||||
project. The original parameterless overload is retained for callers/tests that
|
`services.Configure<DeploymentManagerOptions>(...)` — the same pattern the Host
|
||||||
do not bind configuration. Regression tests:
|
uses for `SecurityOptions`/`InboundApiOptions`. An earlier attempt added an
|
||||||
`AddDeploymentManager_WithConfiguration_BindsDeploymentManagerOptions`,
|
`AddDeploymentManager(IConfiguration)` overload; that was reverted because the
|
||||||
`AddDeploymentManager_WithConfiguration_MissingSection_UsesDefaults`. Note: a
|
project convention (enforced by `Host.Tests.OptionsTests`) forbids component
|
||||||
one-line follow-up in `Host/Program.cs` (call the new overload with
|
`Add*` methods from depending on `IConfiguration` — the Host owns
|
||||||
`builder.Configuration`) is required to take effect at runtime — that file is
|
configuration binding. Regression tests:
|
||||||
outside this module's edit scope and is surfaced for the Host owner.
|
`AddDeploymentManager_RegistersResolvableOptions_WithDefaults`,
|
||||||
|
`AddDeploymentManager_OptionsBindToConfigurationSection_AsTheHostWires`,
|
||||||
|
`OptionsSection_MatchesTheConventionalComponentSectionPath`.
|
||||||
|
|
||||||
### DeploymentManager-009 — Misleading timeout comment on `DeleteInstanceAsync`
|
### DeploymentManager-009 — Misleading timeout comment on `DeleteInstanceAsync`
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 10 |
|
| Open findings | 8 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -98,10 +98,10 @@ response body; it failed before the fix and passes after. Full Host suite green
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium — re-triaged: stale design doc, Host code is correct |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Open |
|
||||||
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:70-108` |
|
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:70-108`, `docs/requirements/Component-Host.md` REQ-HOST-6 |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
@@ -126,7 +126,21 @@ add the plugin packages and HOCON. Either way, code and doc must agree.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
_Verified 2026-05-16, left Open — re-triaged._ The finding is accurate: a repo-wide
|
||||||
|
search confirms there are **no** `PersistentActor` / `ReceivePersistentActor`
|
||||||
|
subclasses anywhere in `src/`, no `akka.persistence` section in the HOCON built by
|
||||||
|
`AkkaHostedService.StartAsync`, and `ScadaLink.Host.csproj` references no persistence
|
||||||
|
plugin packages. The system deliberately uses component-owned SQLite storage
|
||||||
|
services instead. The **Host code is therefore correct and internally consistent** —
|
||||||
|
the only defect is that `docs/requirements/Component-Host.md` REQ-HOST-6 and its
|
||||||
|
Dependencies list still mandate Akka.Persistence, a subsystem that does not (and is
|
||||||
|
not intended to) exist. The sole fix is editing that design document, which lies
|
||||||
|
outside this resolution task's permitted edit scope (`src/ScadaLink.Host`,
|
||||||
|
`tests/ScadaLink.Host.Tests`, `code-reviews/Host/findings.md`). No code or test
|
||||||
|
change can resolve a stale-doc finding. Left **Open** and surfaced for the design-doc
|
||||||
|
owner: REQ-HOST-6 must drop the Akka.Persistence requirement (and the
|
||||||
|
`Akka.Persistence.Hosting` Dependencies entry), stating that node-local persistence
|
||||||
|
is provided by component-owned SQLite storage services.
|
||||||
|
|
||||||
### Host-003 — Secrets committed in plaintext in `appsettings.Central.json`
|
### Host-003 — Secrets committed in plaintext in `appsettings.Central.json`
|
||||||
|
|
||||||
@@ -134,7 +148,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Host/appsettings.Central.json:20-31` |
|
| Location | `src/ScadaLink.Host/appsettings.Central.json:20-31` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -159,7 +173,29 @@ environment.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
|
||||||
|
`appsettings.Central.json`: the committed file carried real-looking secrets in
|
||||||
|
plaintext — SQL Server passwords (`Password=ScadaLink_Dev1#`) in both connection
|
||||||
|
strings, an LDAP service-account password, and a JWT signing key. Fix: all four
|
||||||
|
secrets were removed from the committed file and replaced with non-functional
|
||||||
|
`${...}` placeholder references (`ConfigurationDb` / `MachineDataDb`,
|
||||||
|
`LdapServiceAccountPassword`, `JwtSigningKey`). A new top-level `_secrets` note
|
||||||
|
documents that the Host's configuration builder (`AddEnvironmentVariables()`)
|
||||||
|
overlays the real values supplied via environment variables
|
||||||
|
(`ScadaLink__Database__ConfigurationDb`, `ScadaLink__Database__MachineDataDb`,
|
||||||
|
`ScadaLink__Security__LdapServiceAccountPassword`,
|
||||||
|
`ScadaLink__Security__JwtSigningKey`); the placeholders are intentionally invalid so
|
||||||
|
a misconfigured deployment fails loudly rather than silently using a committed key.
|
||||||
|
Regression test class `ConfigSecretsTests` asserts the committed file carries no
|
||||||
|
plaintext `Password=` value, no committed LDAP service-account password, and no
|
||||||
|
committed JWT signing key; all three tests failed before the fix and pass after.
|
||||||
|
Tests that drive the full `Program` startup pipeline against the real SQL provider
|
||||||
|
(`HealthCheckTests`, `HostStartupTests.CentralRole_StartsWithoutError`) were adapted
|
||||||
|
to supply the local dev connection strings themselves via the new
|
||||||
|
`CentralDbTestEnvironment` test helper (environment variables) — they must no longer
|
||||||
|
depend on committed secrets. Note: the `docker/central-node-*/appsettings.Central.json`
|
||||||
|
files still contain the same dev secrets but lie outside this task's permitted edit
|
||||||
|
scope; they should receive the same treatment in a follow-up.
|
||||||
|
|
||||||
### Host-004 — Site seed-node list points at the gRPC port, not a remoting port
|
### Host-004 — Site seed-node list points at the gRPC port, not a remoting port
|
||||||
|
|
||||||
@@ -167,7 +203,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Host/appsettings.Site.json:10-19` |
|
| Location | `src/ScadaLink.Host/appsettings.Site.json:10-19` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -190,7 +226,23 @@ to reject a seed node whose port equals this node's `GrpcPort`.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
|
||||||
|
`appsettings.Site.json`: with `Node:RemotingPort = 8082` and `Node:GrpcPort = 8083`,
|
||||||
|
the second `Cluster:SeedNodes` entry was `akka.tcp://scadalink@localhost:8083` — the
|
||||||
|
Kestrel HTTP/2 gRPC port, not an Akka.Remote endpoint. `StartupValidator` only
|
||||||
|
checked seed-node *count* (≥2), so the misconfiguration passed silently. Fix, two
|
||||||
|
parts: (1) the shipped `appsettings.Site.json` second seed entry was corrected to a
|
||||||
|
remoting port (`localhost:8084`); (2) `StartupValidator.Validate` was extended — for
|
||||||
|
`Site` nodes it now parses each seed node's TCP port (via a new `SeedNodePort`
|
||||||
|
helper) and rejects any entry whose port equals the node's `GrpcPort`, using the
|
||||||
|
resolved GrpcPort including the `8083` `NodeOptions` default when the key is absent.
|
||||||
|
The seed-node-count check was hoisted above the Site block so the new rule can reuse
|
||||||
|
the parsed list. Regression tests in `StartupValidatorTests`:
|
||||||
|
`Site_SeedNodeOnGrpcPort_FailsValidation`,
|
||||||
|
`Site_SeedNodeOnDefaultGrpcPort_FailsValidation` (default-8083 path),
|
||||||
|
`Site_SeedNodesOnRemotingPort_PassesValidation`, and
|
||||||
|
`Central_SeedNodeOnPort8083_PassesValidation` (rule is Site-only) — all failed
|
||||||
|
appropriately before the fix and pass after.
|
||||||
|
|
||||||
### Host-005 — Blocking sync-over-async (`GetAwaiter().GetResult()`) inside `StartAsync`
|
### Host-005 — Blocking sync-over-async (`GetAwaiter().GetResult()`) inside `StartAsync`
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 10 |
|
| Open findings | 6 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -87,10 +87,10 @@ via `GetOrAdd` so concurrent first-callers share one handler. Regression tests
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium — re-triaged: already fixed by the InboundAPI-001 fix; verified and closed |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:123-129` |
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:152-161` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
@@ -111,7 +111,17 @@ return the handler it produced rather than requiring a separate dictionary read.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): re-triage — verified against the current
|
||||||
|
source, this finding was **already fixed** by the InboundAPI-001 fix. The
|
||||||
|
`InboundScriptExecutor.cs:152-161` lazy-compile path no longer does check-then-act
|
||||||
|
re-read: `Compile(method)` runs unconditionally (it never reads the cache) and the
|
||||||
|
result is published via the atomic `_scriptHandlers.GetOrAdd(method.Name, compiled)`.
|
||||||
|
There is no separate dictionary indexer read, so the `KeyNotFoundException` race the
|
||||||
|
finding describes cannot occur, and concurrent first-callers all share the single
|
||||||
|
handler that `GetOrAdd` keeps. Regression test
|
||||||
|
`LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound` added (asserts a concurrent
|
||||||
|
`RemoveHandler` storm against lazy-compiling callers never yields the catch-all
|
||||||
|
"Internal script error"); it passes against the current code, confirming the fix.
|
||||||
|
|
||||||
### InboundAPI-003 — API key compared with non-constant-time string equality
|
### InboundAPI-003 — API key compared with non-constant-time string equality
|
||||||
|
|
||||||
@@ -161,7 +171,7 @@ longer depends on it.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:117-141` |
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:117-141` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -185,7 +195,17 @@ or use a dedicated timeout `CancellationTokenSource` so the two are separable.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): `ExecuteAsync` now uses a dedicated timeout
|
||||||
|
`CancellationTokenSource` (`new CancellationTokenSource(timeout)`) linked with the
|
||||||
|
request-abort token, so the two cancellation sources are separable. The
|
||||||
|
`OperationCanceledException` handler reports "Script execution timed out" (and logs a
|
||||||
|
warning) **only** when the timeout CTS fired and the request token did not; a client
|
||||||
|
abort instead returns "Request cancelled by client" and logs at Debug — the failure
|
||||||
|
log stays reserved for genuine script-execution timeouts. `HandleInboundApiRequest`
|
||||||
|
additionally short-circuits with `Results.Empty` (no warning log, no 500 body write)
|
||||||
|
when `RequestAborted` is cancelled, since the connection is already gone. Regression
|
||||||
|
tests `ClientDisconnect_IsNotReportedAsTimeout` and `GenuineTimeout_StillReportedAsTimeout`
|
||||||
|
added.
|
||||||
|
|
||||||
### InboundAPI-005 — Compiled API scripts run with no script-trust-model enforcement
|
### InboundAPI-005 — Compiled API scripts run with no script-trust-model enforcement
|
||||||
|
|
||||||
@@ -237,7 +257,7 @@ Regression tests `CompileAndRegister_ForbiddenApi_RejectsScript` (theory),
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:54-62` |
|
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:54-62` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -260,16 +280,24 @@ Reject oversized bodies with 413 before buffering.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): added `InboundApiEndpointFilter`, an
|
||||||
|
`IEndpointFilter` applied to `POST /api/{methodName}` via `.AddEndpointFilter<>()`.
|
||||||
|
It rejects requests whose declared `Content-Length` exceeds `InboundApiOptions.
|
||||||
|
MaxRequestBodyBytes` (default 1 MiB) with HTTP 413 *before* the handler buffers the
|
||||||
|
body into a `JsonDocument`, and also lowers the per-request `IHttpMaxRequestBodySizeFeature`
|
||||||
|
cap so a chunked/unknown-length stream is cut off by Kestrel while being read. The
|
||||||
|
limit is configurable via the bound `ScadaLink:InboundApi` options section. Regression
|
||||||
|
tests `OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler`, `BodyAtLimit_RunsHandler`,
|
||||||
|
and `FilterCapsMaxRequestBodySizeFeature` added.
|
||||||
|
|
||||||
### InboundAPI-007 — `Database.Connection()` script API from the design doc is not implemented
|
### InboundAPI-007 — `Database.Connection()` script API from the design doc is not implemented
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium — verified real drift; left Open pending a design decision (see Resolution) |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Open |
|
||||||
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:155-170` |
|
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:188-203` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
@@ -289,7 +317,27 @@ API.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
_Unresolved — left Open; needs a design decision the resolving agent cannot make._
|
||||||
|
Re-triage 2026-05-16: confirmed against the current source — the drift is **real**.
|
||||||
|
`InboundScriptContext` (`InboundScriptExecutor.cs:188-203`) exposes only `Parameters`,
|
||||||
|
`Route`, and `CancellationToken`; there is no `Database` member, so a method script
|
||||||
|
following the documented `Database.Connection("name")` API fails to compile.
|
||||||
|
|
||||||
|
This finding cannot be closed by the InboundAPI module agent for two reasons:
|
||||||
|
1. **Scope** — the alternative resolution (deleting the "Database Access" section)
|
||||||
|
edits `docs/requirements/Component-InboundAPI.md`, which is outside the editable
|
||||||
|
scope (`src/ScadaLink.InboundAPI`, `tests/`, this file only).
|
||||||
|
2. **It is a genuine design decision.** Implementing `Database.Connection()` is not a
|
||||||
|
mechanical fix: it hands inbound API scripts a *raw* MS SQL client. The ScadaLink
|
||||||
|
script trust model (CLAUDE.md, Akka.NET conventions) forbids scripts from `System.IO`
|
||||||
|
and raw network access, and `ForbiddenApiChecker` (added for InboundAPI-005) now
|
||||||
|
statically blocks `System.Net`/`System.IO`. A raw `SqlConnection` is in clear
|
||||||
|
tension with that trust model, and the set of connection names a script may open,
|
||||||
|
read-only vs. read-write access, and connection lifetime/pooling all require a
|
||||||
|
design call. **Surface to the design owner:** decide whether `Database.Connection()`
|
||||||
|
is in scope — if yes, write a design note covering the trust-model carve-out and
|
||||||
|
then implement a `Database` member backed by a connection-factory service; if no,
|
||||||
|
delete the "Database Access" section from `Component-InboundAPI.md`.
|
||||||
|
|
||||||
### InboundAPI-008 — Inbound API endpoint not restricted to the active central node
|
### InboundAPI-008 — Inbound API endpoint not restricted to the active central node
|
||||||
|
|
||||||
@@ -297,7 +345,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:19-23`, `src/ScadaLink.Host/Program.cs:149` |
|
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:19-23`, `src/ScadaLink.Host/Program.cs:149` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -318,7 +366,19 @@ treated.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): introduced `IActiveNodeGate`, an abstraction
|
||||||
|
the inbound API uses to ask whether this node is the active (cluster-leader) central
|
||||||
|
node. The new `InboundApiEndpointFilter` (applied to `POST /api/{methodName}`)
|
||||||
|
consults the gate and short-circuits a standby node with HTTP 503 before any
|
||||||
|
auth/script work, so Traefik/clients only reach the live node — consistent with
|
||||||
|
`/health/active`. The gate is resolved optionally: when no implementation is
|
||||||
|
registered (non-clustered host / tests) the endpoint defaults to "allow", preserving
|
||||||
|
prior behaviour. Regression tests `StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler`,
|
||||||
|
`ActiveNode_PassesGate_RunsHandler`, and `NoGateRegistered_PassesGate_RunsHandler`
|
||||||
|
added. **Follow-up (outside this module's scope):** `ScadaLink.Host` should register
|
||||||
|
an `IActiveNodeGate` implementation backed by `ActiveNodeHealthCheck` /
|
||||||
|
`Cluster.State.Leader` in the central-role branch of `Program.cs` so the gate is
|
||||||
|
actually enforced in production; until then the endpoint defaults to "allow".
|
||||||
|
|
||||||
### InboundAPI-009 — Failed compilation is retried on every subsequent request
|
### InboundAPI-009 — Failed compilation is retried on every subsequent request
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 10 |
|
| Open findings | 5 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ tests: `IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied`,
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Akka.NET conventions |
|
| Category | Akka.NET conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:61` |
|
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:61` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -196,7 +196,14 @@ that explicit with a router/dispatcher rather than ad-hoc `Task.Run`.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed: `HandleEnvelope` ran every command via
|
||||||
|
`Task.Run` and replied from inside the continuation, contrary to the project's PipeTo
|
||||||
|
convention. Replaced it with a `ProcessCommand` method returning a `Task<object>` and
|
||||||
|
`PipeTo(sender, success, failure)`; faults are now mapped uniformly in a `MapFault` failure
|
||||||
|
continuation (`SiteScopeViolationException` -> `ManagementUnauthorized`, otherwise
|
||||||
|
`ManagementError`), which also unwraps `AggregateException`. Regression test:
|
||||||
|
`UnknownCommandType_FaultMappedToManagementError`. Existing success/error/unauthorized
|
||||||
|
mapping tests confirm behaviour is preserved.
|
||||||
|
|
||||||
### ManagementService-005 — ManagementActor declares no supervision strategy
|
### ManagementService-005 — ManagementActor declares no supervision strategy
|
||||||
|
|
||||||
@@ -231,7 +238,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ManagementService/ManagementEndpoints.cs:83`, `:112` |
|
| Location | `src/ScadaLink.ManagementService/ManagementEndpoints.cs:83`, `:112` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -250,7 +257,16 @@ object, or restructure so the fallback path does not parse a throwaway document.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed: the request `JsonDocument` was never
|
||||||
|
disposed and the empty-payload path allocated a second throwaway `JsonDocument`. Extracted
|
||||||
|
request parsing into a testable `ManagementEndpoints.ParseCommand` helper that wraps the
|
||||||
|
document in `using`; the missing-payload case now deserializes from the `"{}"` literal
|
||||||
|
string rather than parsing a throwaway document. Regression tests:
|
||||||
|
`ParseCommand_WithExplicitPayload_DeserializesIntoCommandType`,
|
||||||
|
`ParseCommand_WithMissingPayload_DeserializesParameterlessCommand`,
|
||||||
|
`ParseCommand_WithInvalidJson_ReturnsFailure`,
|
||||||
|
`ParseCommand_WithMissingCommandField_ReturnsFailure`,
|
||||||
|
`ParseCommand_WithUnknownCommand_ReturnsFailure`.
|
||||||
|
|
||||||
### ManagementService-007 — Inconsistent and cycle-prone serialization of repository entities
|
### ManagementService-007 — Inconsistent and cycle-prone serialization of repository entities
|
||||||
|
|
||||||
@@ -258,7 +274,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:67`; `src/ScadaLink.ManagementService/ManagementEndpoints.cs:113` |
|
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:67`; `src/ScadaLink.ManagementService/ManagementEndpoints.cs:113` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -281,7 +297,14 @@ correctly.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed: the actor serialized results with
|
||||||
|
`Newtonsoft.Json` (not even a direct package reference) while the HTTP endpoint uses
|
||||||
|
`System.Text.Json`. Standardised the actor on `System.Text.Json` via a new
|
||||||
|
`ManagementActor.SerializeResult` helper using a shared `JsonSerializerOptions` with
|
||||||
|
`ReferenceHandler.IgnoreCycles` (cycle-safe for EF entity graphs) and camelCase naming
|
||||||
|
(matches the CLI's case-insensitive deserializer). Removed the `Newtonsoft.Json` import.
|
||||||
|
Regression tests: `SerializeResult_WithCyclicGraph_DoesNotThrow`,
|
||||||
|
`SerializeResult_UsesCamelCasePropertyNames`.
|
||||||
|
|
||||||
### ManagementService-008 — HandleResolveRoles constructs RoleMapper manually instead of via DI
|
### ManagementService-008 — HandleResolveRoles constructs RoleMapper manually instead of via DI
|
||||||
|
|
||||||
@@ -312,9 +335,9 @@ _Unresolved._
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Low — re-triaged from Medium; the claimed audit gap does not exist (see Description), leaving only an undocumented-convention issue. |
|
||||||
| Category | Error handling & resilience |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:357`, `:1134`, `:1085`, `:526`, `:1275` |
|
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:357`, `:1134`, `:1085`, `:526`, `:1275` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -325,21 +348,36 @@ external-system/notification/security/area mutations), but the handlers that del
|
|||||||
domain service do **not** — `HandleCreateTemplate`/`HandleUpdateTemplate`/`HandleDeleteTemplate`,
|
domain service do **not** — `HandleCreateTemplate`/`HandleUpdateTemplate`/`HandleDeleteTemplate`,
|
||||||
all template-member handlers (`HandleAddAttribute` ... `HandleDeleteComposition`), template-folder
|
all template-member handlers (`HandleAddAttribute` ... `HandleDeleteComposition`), template-folder
|
||||||
handlers, shared-script handlers, `HandleDeployArtifacts`, `HandleDeployInstance`,
|
handlers, shared-script handlers, `HandleDeployArtifacts`, `HandleDeployInstance`,
|
||||||
`HandleEnableInstance`/`Disable`/`Delete`, and the instance-binding/override handlers. This is
|
`HandleEnableInstance`/`Disable`/`Delete`, and the instance-binding/override handlers.
|
||||||
correct only if every one of those services performs its own audit logging internally; the
|
|
||||||
mixed pattern makes that impossible to verify by reading this module and creates a real risk
|
**Re-triage (2026-05-16):** the original finding claimed this "creates a real risk of silent
|
||||||
of silent audit gaps for template authoring and deployment operations.
|
audit gaps for template authoring and deployment operations." That claim was verified against
|
||||||
|
the actual sources and is **false**. Every domain service the delegating handlers call —
|
||||||
|
`TemplateService`, `SharedScriptService`, `InstanceService`, `AreaService`, `SiteService`,
|
||||||
|
`TemplateFolderService`, `DeploymentService`, `ArtifactDeploymentService` — injects
|
||||||
|
`IAuditService` and calls `LogAsync` on every mutation (`grep` confirms an `_auditService.LogAsync`
|
||||||
|
call after each `Create`/`Update`/`Delete` in `TemplateService.cs`, `DeploymentService.cs`,
|
||||||
|
`ArtifactDeploymentService.cs`, etc.). There is therefore no audit gap; if anything, adding
|
||||||
|
explicit `AuditAsync` to a delegating handler would *double-log*. The genuine issue is purely
|
||||||
|
organizational: the two-layer split (actor audits repo-direct mutations, services audit their
|
||||||
|
own) was undocumented, which is what made it look risky. This is a Low-severity
|
||||||
|
code-organization issue, not a Medium error-handling/resilience defect.
|
||||||
|
|
||||||
**Recommendation**
|
**Recommendation**
|
||||||
|
|
||||||
Decide on one layer that owns auditing. Either route all mutations through services that audit
|
Document the chosen contract so the split cannot be misread as a gap. (The original
|
||||||
internally (and remove the explicit `AuditAsync` calls here), or audit uniformly in the actor
|
alternative — moving all auditing into the actor — would require un-auditing eight services
|
||||||
after every successful mutation. Document the chosen contract so the inconsistency cannot
|
and is not warranted given they already audit correctly.)
|
||||||
recur, and confirm template/deployment services actually audit.
|
|
||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Re-triaged to Low / Code organization after verifying
|
||||||
|
all eight delegated-to services audit internally — no audit gap exists. Documented the
|
||||||
|
two-layer audit contract in an XML `<remarks>` block on `ManagementActor.AuditAsync`:
|
||||||
|
repository-direct mutations call `AuditAsync`; service-delegating handlers must not, because
|
||||||
|
the services own auditing and a duplicate call would double-log. No behavioural change, so
|
||||||
|
no new regression test; existing `CreateInstanceCommand_WithDeploymentRole_ReturnsSuccess`
|
||||||
|
covers the explicit-audit path.
|
||||||
|
|
||||||
### ManagementService-010 — ManagementServiceOptions.CommandTimeout is defined but never used
|
### ManagementService-010 — ManagementServiceOptions.CommandTimeout is defined but never used
|
||||||
|
|
||||||
@@ -433,7 +471,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs:1` |
|
| Location | `tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs:1` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -457,4 +495,14 @@ malformed bodies, unknown commands, and the 200/400/403/401/504 mappings.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). The site-scope and `DebugStreamHub` coverage gaps
|
||||||
|
were closed by the resolution of findings 001/002/003 (the `ScopedEnvelope` helper plus the
|
||||||
|
`*_OutOfScopeForSiteScopedUser_ReturnsUnauthorized` tests and `DebugStreamHubTests`). The
|
||||||
|
remaining HTTP-endpoint gap is now covered by a new `ManagementEndpointsTests.cs` exercising
|
||||||
|
`ManagementEndpoints.ParseCommand` — command deserialization, malformed JSON, missing
|
||||||
|
`command` field, and unknown commands. Full `WebApplicationFactory` auth-flow tests were
|
||||||
|
deliberately not added: `ManagementEndpoints` depends on `LdapAuthService` and live LDAP
|
||||||
|
infrastructure, so the testable command-parsing/dispatch logic was extracted into the pure
|
||||||
|
`ParseCommand` helper and covered instead. Tests: `ParseCommand_*` (5),
|
||||||
|
`SerializeResult_*` (2), `UnknownCommandType_FaultMappedToManagementError`, plus the
|
||||||
|
pre-existing site-scope and DebugStreamHub suites. `dotnet test` -> 48 passed.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 8 |
|
| Open findings | 3 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ the resulting client is disposed.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:18`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:123` |
|
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:18`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:123` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -185,7 +185,16 @@ Pass the `TlsMode` string (or a `TlsMode` enum) through to the wrapper and map e
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
|
||||||
|
`bool useTls` parameter cannot represent three states, and the non-StartTLS branch used
|
||||||
|
`SecureSocketOptions.Auto`. A new `SmtpTlsMode` enum (`None`/`StartTls`/`Ssl`) and
|
||||||
|
`SmtpTlsModeParser` were added; `ISmtpClientWrapper.ConnectAsync` now takes `SmtpTlsMode`
|
||||||
|
and `MailKitSmtpClientWrapper` maps it explicitly to `SecureSocketOptions.None`/
|
||||||
|
`StartTls`/`SslOnConnect`. `SendAsync`/`DeliverBufferedAsync` validate the configured
|
||||||
|
`TlsMode` up front — an unknown value returns a clean `NotificationResult` failure (or
|
||||||
|
parks a buffered message) instead of silently negotiating TLS. Regression tests:
|
||||||
|
`Send_TlsModeNone_DoesNotNegotiateTls`, `Send_TlsModeSsl_UsesImplicitSsl`,
|
||||||
|
`Send_UnknownTlsMode_ReturnsErrorNotSilentFallback`, and the `SmtpTlsModeParserTests` set.
|
||||||
|
|
||||||
### NotificationService-006 — OAuth2 token cache is keyed to nothing; wrong token returned when multiple SMTP configs exist
|
### NotificationService-006 — OAuth2 token cache is keyed to nothing; wrong token returned when multiple SMTP configs exist
|
||||||
|
|
||||||
@@ -193,7 +202,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.NotificationService/OAuth2TokenService.cs:14-15`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-35` |
|
| Location | `src/ScadaLink.NotificationService/OAuth2TokenService.cs:14-15`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-35` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -206,7 +215,14 @@ Key the cache by the credential identity (e.g. a dictionary keyed by `tenantId:c
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed: the singleton held a single
|
||||||
|
`_cachedToken`/`_tokenExpiry` pair and `GetTokenAsync` ignored the `credentials` argument
|
||||||
|
when validating the cache, so a second SMTP config got the first config's token.
|
||||||
|
`OAuth2TokenService` now stores a `ConcurrentDictionary<string, CacheEntry>` keyed by the
|
||||||
|
SHA-256 hash of the credential string; each distinct tenant/client/secret gets its own
|
||||||
|
cached token, expiry, and per-credential `SemaphoreSlim` (double-checked locking
|
||||||
|
preserved). Regression tests: `GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens`
|
||||||
|
and `GetTokenAsync_SameCredentials_CachedPerCredential`.
|
||||||
|
|
||||||
### NotificationService-007 — Connection timeout and max-concurrent-connections from the design doc are not implemented
|
### NotificationService-007 — Connection timeout and max-concurrent-connections from the design doc are not implemented
|
||||||
|
|
||||||
@@ -214,7 +230,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.NotificationService/NotificationOptions.cs:11-14`, `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:16-20`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:111-140` |
|
| Location | `src/ScadaLink.NotificationService/NotificationOptions.cs:11-14`, `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:16-20`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:111-140` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -227,7 +243,17 @@ Set `SmtpClient.Timeout` from `ConnectionTimeoutSeconds` in `ConnectAsync` (and/
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed: `ConnectAsync` never set
|
||||||
|
`SmtpClient.Timeout` and no throttle gated `DeliverAsync`. `ISmtpClientWrapper.ConnectAsync`
|
||||||
|
now takes a `connectionTimeoutSeconds` argument; `MailKitSmtpClientWrapper` sets
|
||||||
|
`SmtpClient.Timeout` from `SmtpConfiguration.ConnectionTimeoutSeconds`. `DeliverAsync`
|
||||||
|
acquires a lazily-created `SemaphoreSlim` sized to `SmtpConfiguration.MaxConcurrentConnections`
|
||||||
|
(default 5 when non-positive) and releases it in a `finally`, so concurrent SMTP
|
||||||
|
deliveries per site are bounded. The timeout is sourced from the deployed
|
||||||
|
`SmtpConfiguration` rather than `NotificationOptions`; the `NotificationOptions` fields
|
||||||
|
remain as operational fallback defaults. Regression tests:
|
||||||
|
`Send_PassesConfiguredConnectionTimeoutToClient` and
|
||||||
|
`Send_MaxConcurrentConnections_LimitsConcurrentDeliveries`.
|
||||||
|
|
||||||
### NotificationService-008 — Recipient email addresses are not validated before send
|
### NotificationService-008 — Recipient email addresses are not validated before send
|
||||||
|
|
||||||
@@ -235,7 +261,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:136-137`, `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:50-53` |
|
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:136-137`, `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:50-53` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -248,15 +274,24 @@ Validate addresses up front (e.g. `MailboxAddress.TryParse`) and return a `Notif
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause confirmed: `MailboxAddress.Parse` of a
|
||||||
|
malformed `FromAddress`/recipient threw `ParseException`, which is unclassified and
|
||||||
|
escaped `SendAsync` as an unhandled exception. A new `ValidateAddresses` helper uses
|
||||||
|
`MailboxAddress.TryParse` for the sender and every recipient; `SendAsync` now returns a
|
||||||
|
clean `NotificationResult(false, ...)` listing the invalid address(es) before any SMTP
|
||||||
|
attempt, and `DeliverBufferedAsync` parks a buffered message with a bad address (a fault
|
||||||
|
retrying cannot fix). Regression tests:
|
||||||
|
`Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow` and
|
||||||
|
`Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow`. Definition-time validation in
|
||||||
|
the Central UI is a separate component and out of this module's scope.
|
||||||
|
|
||||||
### NotificationService-009 — Credentials handled as plaintext strings; OAuth2 client secret logged risk
|
### NotificationService-009 — Credentials handled as plaintext strings; OAuth2 client secret logged risk
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium — re-triaged: split into an in-scope log-leak fix (resolved) and a Commons-scoped at-rest-encryption / structured-credential follow-up (NotificationService-013, Deferred). |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:127-134`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-65`, `src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9` |
|
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:127-134`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-65`, `src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -269,7 +304,55 @@ Store credentials encrypted at rest (DPAPI/Data Protection or a secret store) an
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Root cause re-triaged against source: the finding
|
||||||
|
conflates two concerns with different ownership.
|
||||||
|
|
||||||
|
1. **Log-leak risk (in scope — fixed).** The original code logged whole exception objects
|
||||||
|
(`_logger.LogWarning(ex, ...)` / `LogError(ex, ...)`); MailKit auth exceptions can echo
|
||||||
|
server responses quoting the supplied credentials. A new internal `CredentialRedactor`
|
||||||
|
masks every colon-delimited credential component out of any text. `SendAsync` and
|
||||||
|
`DeliverBufferedAsync` now log a scrubbed message string (not the raw exception) and the
|
||||||
|
permanent-failure `NotificationResult` is scrubbed before it returns to the caller.
|
||||||
|
`OAuth2TokenService` logs the tenant id only — never the client secret or access token.
|
||||||
|
Regression tests: `CredentialRedactorTests` and
|
||||||
|
`Send_PermanentError_RedactsCredentialFromResultMessage`.
|
||||||
|
2. **At-rest encryption + structured-credential modelling (out of scope — Deferred).**
|
||||||
|
Encrypting `SmtpConfiguration.Credentials` at rest and replacing the brittle
|
||||||
|
colon-packed `string` with structured fields requires editing
|
||||||
|
`src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs` and the
|
||||||
|
ConfigurationDatabase EF layer — both outside this module. Tracked separately as
|
||||||
|
**NotificationService-013** (Deferred) so it is not lost.
|
||||||
|
|
||||||
|
### NotificationService-013 — Encrypt SMTP credentials at rest; replace colon-packed string with structured fields
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| Severity | Medium |
|
||||||
|
| Category | Security |
|
||||||
|
| Status | Deferred |
|
||||||
|
| Location | `src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9`, ConfigurationDatabase EF mapping |
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
Split out of NotificationService-009. `SmtpConfiguration.Credentials` packs Basic Auth
|
||||||
|
`user:pass` and OAuth2 `tenantId:clientId:clientSecret` into a single plaintext
|
||||||
|
colon-delimited `string`: (a) there is no encryption at rest in SQLite or the central
|
||||||
|
config DB; (b) a password or client secret containing a `:` is split incorrectly by
|
||||||
|
`Split(':', 2)` / `Split(':', 3)`, silently corrupting the secret.
|
||||||
|
|
||||||
|
**Recommendation**
|
||||||
|
|
||||||
|
Model credentials as structured fields (or an encrypted blob) on the Commons entity and
|
||||||
|
encrypt at rest via Data Protection / a secret store. The colon-delimited parsing in
|
||||||
|
`MailKitSmtpClientWrapper` and `OAuth2TokenService` would then consume the structured
|
||||||
|
fields directly.
|
||||||
|
|
||||||
|
**Resolution**
|
||||||
|
|
||||||
|
Deferred — requires changes to `src/ScadaLink.Commons` and the ConfigurationDatabase
|
||||||
|
component, which are outside the NotificationService module. To be addressed in a
|
||||||
|
Commons/ConfigurationDatabase-scoped change. The associated log-leak risk is resolved
|
||||||
|
under NotificationService-009.
|
||||||
|
|
||||||
### NotificationService-010 — `DeliverAsync` does not disconnect the SMTP client on failure
|
### NotificationService-010 — `DeliverAsync` does not disconnect the SMTP client on failure
|
||||||
|
|
||||||
|
|||||||
+8
-28
@@ -41,9 +41,9 @@ module file and counted in **Total**.
|
|||||||
|----------|---------------|
|
|----------|---------------|
|
||||||
| Critical | 0 |
|
| Critical | 0 |
|
||||||
| High | 0 |
|
| High | 0 |
|
||||||
| Medium | 45 |
|
| Medium | 25 |
|
||||||
| Low | 90 |
|
| Low | 90 |
|
||||||
| **Total** | **135** |
|
| **Total** | **115** |
|
||||||
|
|
||||||
## Module Status
|
## Module Status
|
||||||
|
|
||||||
@@ -59,11 +59,11 @@ module file and counted in **Total**.
|
|||||||
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/5 | 5 | 14 |
|
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/5 | 5 | 14 |
|
||||||
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/4 | 4 | 14 |
|
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/4 | 4 | 14 |
|
||||||
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/5 | 5 | 12 |
|
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/5 | 5 | 12 |
|
||||||
| [Host](Host/findings.md) | 2026-05-16 | `9c60592` | 0/0/3/7 | 10 | 11 |
|
| [Host](Host/findings.md) | 2026-05-16 | `9c60592` | 0/0/1/7 | 8 | 11 |
|
||||||
| [InboundAPI](InboundAPI/findings.md) | 2026-05-16 | `9c60592` | 0/0/5/5 | 10 | 13 |
|
| [InboundAPI](InboundAPI/findings.md) | 2026-05-16 | `9c60592` | 0/0/1/5 | 6 | 13 |
|
||||||
| [ManagementService](ManagementService/findings.md) | 2026-05-16 | `9c60592` | 0/0/5/5 | 10 | 13 |
|
| [ManagementService](ManagementService/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/5 | 5 | 13 |
|
||||||
| [NotificationService](NotificationService/findings.md) | 2026-05-16 | `9c60592` | 0/0/5/3 | 8 | 12 |
|
| [NotificationService](NotificationService/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/3 | 3 | 13 |
|
||||||
| [Security](Security/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/4 | 8 | 11 |
|
| [Security](Security/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/4 | 4 | 11 |
|
||||||
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/3 | 7 | 11 |
|
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/3 | 7 | 11 |
|
||||||
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-16 | `9c60592` | 0/0/8/5 | 13 | 16 |
|
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-16 | `9c60592` | 0/0/8/5 | 13 | 16 |
|
||||||
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/7 | 11 | 14 |
|
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/7 | 11 | 14 |
|
||||||
@@ -84,34 +84,14 @@ _None open._
|
|||||||
|
|
||||||
_None open._
|
_None open._
|
||||||
|
|
||||||
### Medium (45)
|
### Medium (25)
|
||||||
|
|
||||||
| ID | Module | Title |
|
| ID | Module | Title |
|
||||||
|----|--------|-------|
|
|----|--------|-------|
|
||||||
| CentralUI-005 | [CentralUI](CentralUI/findings.md) | Session expiry implementation diverges from the documented policy |
|
| CentralUI-005 | [CentralUI](CentralUI/findings.md) | Session expiry implementation diverges from the documented policy |
|
||||||
| CentralUI-006 | [CentralUI](CentralUI/findings.md) | Deployment status page polls every 10s despite the documented SignalR-push design |
|
| CentralUI-006 | [CentralUI](CentralUI/findings.md) | Deployment status page polls every 10s despite the documented SignalR-push design |
|
||||||
| Host-002 | [Host](Host/findings.md) | Akka.Persistence required by REQ-HOST-6 is not configured and not used |
|
| Host-002 | [Host](Host/findings.md) | Akka.Persistence required by REQ-HOST-6 is not configured and not used |
|
||||||
| Host-003 | [Host](Host/findings.md) | Secrets committed in plaintext in `appsettings.Central.json` |
|
|
||||||
| Host-004 | [Host](Host/findings.md) | Site seed-node list points at the gRPC port, not a remoting port |
|
|
||||||
| InboundAPI-002 | [InboundAPI](InboundAPI/findings.md) | Lazy compilation is a check-then-act race with no atomicity |
|
|
||||||
| InboundAPI-004 | [InboundAPI](InboundAPI/findings.md) | Client disconnect is misreported as a script timeout |
|
|
||||||
| InboundAPI-006 | [InboundAPI](InboundAPI/findings.md) | No request body size limit on the inbound endpoint |
|
|
||||||
| InboundAPI-007 | [InboundAPI](InboundAPI/findings.md) | `Database.Connection()` script API from the design doc is not implemented |
|
| InboundAPI-007 | [InboundAPI](InboundAPI/findings.md) | `Database.Connection()` script API from the design doc is not implemented |
|
||||||
| InboundAPI-008 | [InboundAPI](InboundAPI/findings.md) | Inbound API endpoint not restricted to the active central node |
|
|
||||||
| ManagementService-004 | [ManagementService](ManagementService/findings.md) | Actor offloads work to Task.Run instead of using PipeTo |
|
|
||||||
| ManagementService-006 | [ManagementService](ManagementService/findings.md) | JsonDocument instances never disposed in the HTTP endpoint |
|
|
||||||
| ManagementService-007 | [ManagementService](ManagementService/findings.md) | Inconsistent and cycle-prone serialization of repository entities |
|
|
||||||
| ManagementService-009 | [ManagementService](ManagementService/findings.md) | Audit logging applied inconsistently across mutating handlers |
|
|
||||||
| ManagementService-013 | [ManagementService](ManagementService/findings.md) | No tests for site-scope enforcement, the HTTP endpoint, or DebugStreamHub |
|
|
||||||
| NotificationService-005 | [NotificationService](NotificationService/findings.md) | Non-TLS path uses `SecureSocketOptions.Auto`, contradicting the requested mode |
|
|
||||||
| NotificationService-006 | [NotificationService](NotificationService/findings.md) | OAuth2 token cache is keyed to nothing; wrong token returned when multiple SMTP configs exist |
|
|
||||||
| NotificationService-007 | [NotificationService](NotificationService/findings.md) | Connection timeout and max-concurrent-connections from the design doc are not implemented |
|
|
||||||
| NotificationService-008 | [NotificationService](NotificationService/findings.md) | Recipient email addresses are not validated before send |
|
|
||||||
| NotificationService-009 | [NotificationService](NotificationService/findings.md) | Credentials handled as plaintext strings; OAuth2 client secret logged risk |
|
|
||||||
| Security-004 | [Security](Security/findings.md) | Search filter uses `uid=` while fallback DN construction uses `cn=` |
|
|
||||||
| Security-005 | [Security](Security/findings.md) | DN injection in the no-service-account bind fallback |
|
|
||||||
| Security-006 | [Security](Security/findings.md) | JWT validation disables issuer and audience checks |
|
|
||||||
| Security-007 | [Security](Security/findings.md) | Idle-timeout claim is reset on every token refresh |
|
|
||||||
| SiteEventLogging-005 | [SiteEventLogging](SiteEventLogging/findings.md) | `LogEventAsync` performs synchronous disk I/O on the caller's thread |
|
| SiteEventLogging-005 | [SiteEventLogging](SiteEventLogging/findings.md) | `LogEventAsync` performs synchronous disk I/O on the caller's thread |
|
||||||
| SiteEventLogging-007 | [SiteEventLogging](SiteEventLogging/findings.md) | `ISiteEventLogger` consumers downcast to the concrete type and reach into the DB connection |
|
| SiteEventLogging-007 | [SiteEventLogging](SiteEventLogging/findings.md) | `ISiteEventLogger` consumers downcast to the concrete type and reach into the DB connection |
|
||||||
| SiteEventLogging-008 | [SiteEventLogging](SiteEventLogging/findings.md) | Event-recording write failures are silently swallowed |
|
| SiteEventLogging-008 | [SiteEventLogging](SiteEventLogging/findings.md) | Event-recording write failures are silently swallowed |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 8 |
|
| Open findings | 4 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ corrected to state the requirement. Regression tests
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Security/LdapAuthService.cs:66`, `:138`, `:157-159` |
|
| Location | `src/ScadaLink.Security/LdapAuthService.cs:66`, `:138`, `:157-159` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -176,7 +176,15 @@ consistently in both the search filter and the fallback DN. Update the XML doc t
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Confirmed: the search filter was hard-coded
|
||||||
|
`(uid={username})` (both in `AuthenticateAsync` and `ResolveUserDnAsync`) while the
|
||||||
|
fallback DN used `cn={username}` — the two auth modes were not interchangeable. Added
|
||||||
|
a configurable `SecurityOptions.LdapUserIdAttribute` (default `uid`) used for both the
|
||||||
|
search filter and the fallback DN via the new `BuildFallbackUserDn` helper, and
|
||||||
|
corrected the `LdapServiceAccountDn` XML doc to reference `{LdapUserIdAttribute}`.
|
||||||
|
Regression tests `BuildFallbackUserDn_UsesConfiguredUserIdAttribute`,
|
||||||
|
`BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute`,
|
||||||
|
`SecurityOptions_LdapUserIdAttribute_DefaultsToUid`.
|
||||||
|
|
||||||
### Security-005 — DN injection in the no-service-account bind fallback
|
### Security-005 — DN injection in the no-service-account bind fallback
|
||||||
|
|
||||||
@@ -184,7 +192,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Security/LdapAuthService.cs:157-159` |
|
| Location | `src/ScadaLink.Security/LdapAuthService.cs:157-159` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -207,7 +215,15 @@ raw DN from untrusted input is risky; restrict it or remove it.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Confirmed: the fallback path interpolated
|
||||||
|
the raw `username` straight into `cn={username},{LdapSearchBase}` with no DN escaping,
|
||||||
|
and the `username.Contains('=')` shortcut let a caller supply an arbitrary bind DN.
|
||||||
|
Added an RFC 4514 `EscapeLdapDn` helper (escapes `, + " \ < > ;`, leading/trailing
|
||||||
|
space, leading `#`, NUL) applied in `BuildFallbackUserDn`, so a username such as
|
||||||
|
`victim,ou=admins` can no longer alter the DN structure. The `Contains('=')` raw-DN
|
||||||
|
shortcut was removed entirely — untrusted input no longer controls the bind identity.
|
||||||
|
Regression tests `BuildFallbackUserDn_EscapesDnMetacharacters`,
|
||||||
|
`EscapeLdapDn_EscapesAllRfc4514Specials`, `EscapeLdapDn_EscapesLeadingAndTrailingSpaces`.
|
||||||
|
|
||||||
### Security-006 — JWT validation disables issuer and audience checks
|
### Security-006 — JWT validation disables issuer and audience checks
|
||||||
|
|
||||||
@@ -215,7 +231,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Security/JwtTokenService.cs:67-75`, `:56-59` |
|
| Location | `src/ScadaLink.Security/JwtTokenService.cs:67-75`, `:56-59` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -236,7 +252,14 @@ validation.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Confirmed: `GenerateToken` set neither `iss`
|
||||||
|
nor `aud` and `ValidateToken` had `ValidateIssuer = false`/`ValidateAudience = false`.
|
||||||
|
`GenerateToken` now binds `JwtTokenService.TokenIssuer`/`TokenAudience`
|
||||||
|
(both `"scadalink-central"`) into every token, and `ValidateToken` enables
|
||||||
|
`ValidateIssuer`/`ValidateAudience` against those fixed values — a token signed with
|
||||||
|
the shared key but a foreign issuer is now rejected. Regression tests
|
||||||
|
`GenerateToken_SetsIssuerAndAudience`, `ValidateToken_RejectsTokenWithWrongIssuer`,
|
||||||
|
`ValidateToken_AcceptsOwnIssuerAndAudience`.
|
||||||
|
|
||||||
### Security-007 — Idle-timeout claim is reset on every token refresh
|
### Security-007 — Idle-timeout claim is reset on every token refresh
|
||||||
|
|
||||||
@@ -244,7 +267,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Security/JwtTokenService.cs:40`, `:111-123` |
|
| Location | `src/ScadaLink.Security/JwtTokenService.cs:40`, `:111-123` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -269,7 +292,22 @@ path agree on the semantics.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Confirmed: `RefreshToken` → `GenerateToken`
|
||||||
|
unconditionally wrote `LastActivity = UtcNow`, so the idle clock reset on every
|
||||||
|
refresh and the documented 30-minute idle timeout could never fire for a client that
|
||||||
|
polls in the background. Implemented option (a) — the Security-side half of the
|
||||||
|
documented "15-min sliding + 30-min idle" policy (the cross-module partner of
|
||||||
|
CentralUI-005): `GenerateToken` now takes an optional `lastActivity` anchor;
|
||||||
|
`RefreshToken` carries the **existing** `LastActivity` claim forward unchanged, and a
|
||||||
|
new explicit `RecordActivity` method advances the anchor to now — to be called by the
|
||||||
|
CentralUI request pipeline on genuine user interaction (not on a background refresh).
|
||||||
|
`IsIdleTimedOut` is unchanged and now agrees with the refresh path. The remaining
|
||||||
|
CentralUI-side wiring (calling `RecordActivity` from the middleware, setting
|
||||||
|
`SlidingExpiration`/`ExpireTimeSpan`) stays tracked under CentralUI-005; this finding's
|
||||||
|
Security-side defect — the reset-on-refresh bug — is fully fixed here. Regression tests
|
||||||
|
`RefreshToken_PreservesOriginalLastActivityClaim`,
|
||||||
|
`RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle`,
|
||||||
|
`RecordActivity_UpdatesLastActivityToNow`.
|
||||||
|
|
||||||
### Security-008 — N+1 query loading site-scope rules in `RoleMapper`
|
### Security-008 — N+1 query loading site-scope rules in `RoleMapper`
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
private volatile bool _ready;
|
private volatile bool _ready;
|
||||||
private long _actorCounter;
|
private long _actorCounter;
|
||||||
|
|
||||||
public SiteStreamGrpcServer(
|
/// <summary>
|
||||||
|
/// Test-only constructor — kept <c>internal</c> so the DI container sees a
|
||||||
|
/// single public constructor and is not faced with an ambiguous choice.
|
||||||
|
/// </summary>
|
||||||
|
internal SiteStreamGrpcServer(
|
||||||
ISiteStreamSubscriber streamSubscriber,
|
ISiteStreamSubscriber streamSubscriber,
|
||||||
ILogger<SiteStreamGrpcServer> logger,
|
ILogger<SiteStreamGrpcServer> logger,
|
||||||
int maxConcurrentStreams = 100)
|
int maxConcurrentStreams = 100)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="ScadaLink.Communication.Tests" />
|
<InternalsVisibleTo Include="ScadaLink.Communication.Tests" />
|
||||||
|
<InternalsVisibleTo Include="ScadaLink.IntegrationTests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace ScadaLink.DeploymentManager;
|
namespace ScadaLink.DeploymentManager;
|
||||||
@@ -7,37 +6,26 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configuration section that <see cref="DeploymentManagerOptions"/> is bound to.
|
/// Configuration section that <see cref="DeploymentManagerOptions"/> is bound to.
|
||||||
|
/// The Host binds this section to <c>appsettings.json</c> (see
|
||||||
|
/// <c>Program.cs</c>); component libraries do not depend on
|
||||||
|
/// <c>IConfiguration</c> directly, consistent with the Options-pattern
|
||||||
|
/// convention enforced by <c>OptionsTests</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string OptionsSection = "ScadaLink:DeploymentManager";
|
public const string OptionsSection = "ScadaLink:DeploymentManager";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the Deployment Manager services and binds
|
/// Registers the Deployment Manager services. <see cref="DeploymentManagerOptions"/>
|
||||||
/// <see cref="DeploymentManagerOptions"/> to the
|
/// is registered via <see cref="OptionsServiceCollectionExtensions.AddOptions"/> so
|
||||||
/// <see cref="OptionsSection"/> configuration section, consistent with the
|
/// <c>IOptions<DeploymentManagerOptions></c> is always resolvable; the Host
|
||||||
/// Options-pattern convention ("Per-component configuration via
|
/// binds <see cref="OptionsSection"/> to configuration so the operation-lock and
|
||||||
/// appsettings.json sections bound to options classes").
|
/// artifact-deployment timeouts are tunable via <c>appsettings.json</c>.
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddDeploymentManager(
|
|
||||||
this IServiceCollection services,
|
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(configuration);
|
|
||||||
|
|
||||||
// DeploymentManager-008: bind the options class so the operation-lock
|
|
||||||
// and artifact-deployment timeouts are tunable via appsettings.json.
|
|
||||||
services.Configure<DeploymentManagerOptions>(configuration.GetSection(OptionsSection));
|
|
||||||
return services.AddDeploymentManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers the Deployment Manager services without binding options to
|
|
||||||
/// configuration. <see cref="DeploymentManagerOptions"/> falls back to its
|
|
||||||
/// declared defaults unless configured elsewhere. Prefer the
|
|
||||||
/// <see cref="AddDeploymentManager(IServiceCollection, IConfiguration)"/>
|
|
||||||
/// overload so the options are bound to <c>appsettings.json</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddDeploymentManager(this IServiceCollection services)
|
public static IServiceCollection AddDeploymentManager(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
// DeploymentManager-008: ensure the options class is always resolvable.
|
||||||
|
// The Host binds OptionsSection to appsettings.json; absent that binding
|
||||||
|
// the declared option-class defaults apply.
|
||||||
|
services.AddOptions<DeploymentManagerOptions>();
|
||||||
services.AddSingleton<OperationLockManager>();
|
services.AddSingleton<OperationLockManager>();
|
||||||
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
|
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
|
||||||
services.AddScoped<DeploymentService>();
|
services.AddScoped<DeploymentService>();
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ try
|
|||||||
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
|
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
|
||||||
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
||||||
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
|
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
|
||||||
|
builder.Services.Configure<DeploymentManagerOptions>(
|
||||||
|
builder.Configuration.GetSection(ScadaLink.DeploymentManager.ServiceCollectionExtensions.OptionsSection));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -40,23 +40,55 @@ public static class StartupValidator
|
|||||||
errors.Add("ScadaLink:Security:JwtSigningKey required for Central");
|
errors.Add("ScadaLink:Security:JwtSigningKey required for Central");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get<List<string>>();
|
||||||
|
if (seedNodes == null || seedNodes.Count < 2)
|
||||||
|
errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries");
|
||||||
|
|
||||||
if (role == "Site")
|
if (role == "Site")
|
||||||
{
|
{
|
||||||
var grpcPortStr = nodeSection["GrpcPort"];
|
var grpcPortStr = nodeSection["GrpcPort"];
|
||||||
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out var gp) || gp < 1 || gp > 65535))
|
int grpcPort = 8083; // NodeOptions default when the key is absent
|
||||||
|
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535))
|
||||||
errors.Add("ScadaLink:Node:GrpcPort must be 1-65535");
|
errors.Add("ScadaLink:Node:GrpcPort must be 1-65535");
|
||||||
|
|
||||||
var dbSection = configuration.GetSection("ScadaLink:Database");
|
var dbSection = configuration.GetSection("ScadaLink:Database");
|
||||||
if (string.IsNullOrEmpty(dbSection["SiteDbPath"]))
|
if (string.IsNullOrEmpty(dbSection["SiteDbPath"]))
|
||||||
errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes");
|
errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes");
|
||||||
}
|
|
||||||
|
|
||||||
var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get<List<string>>();
|
// Host-004: a seed node must reference an Akka.Remote endpoint, never the
|
||||||
if (seedNodes == null || seedNodes.Count < 2)
|
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's
|
||||||
errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries");
|
// GrpcPort would make a joining node attempt an Akka.Remote TCP
|
||||||
|
// association against the gRPC listener and fail.
|
||||||
|
if (seedNodes != null)
|
||||||
|
{
|
||||||
|
foreach (var seed in seedNodes)
|
||||||
|
{
|
||||||
|
if (SeedNodePort(seed) == grpcPort)
|
||||||
|
errors.Add(
|
||||||
|
$"ScadaLink:Cluster:SeedNodes entry '{seed}' must not target the gRPC port " +
|
||||||
|
$"({grpcPort}); seed nodes must reference Akka remoting ports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");
|
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the TCP port from an Akka seed-node address of the form
|
||||||
|
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
|
||||||
|
/// </summary>
|
||||||
|
private static int SeedNodePort(string seedNode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(seedNode))
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
var lastColon = seedNode.LastIndexOf(':');
|
||||||
|
if (lastColon < 0 || lastColon == seedNode.Length - 1)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return int.TryParse(seedNode[(lastColon + 1)..], out var port) ? port : -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1
|
||||||
},
|
},
|
||||||
|
"_secrets": "Host-003: Secrets are NOT committed in this file. Supply them via environment variables, which the Host's configuration builder (AddEnvironmentVariables) overlays over this file. Required: ScadaLink__Database__ConfigurationDb, ScadaLink__Database__MachineDataDb, ScadaLink__Security__LdapServiceAccountPassword, ScadaLink__Security__JwtSigningKey. The ${...} placeholders below are intentionally non-functional and must be overridden per environment.",
|
||||||
"Database": {
|
"Database": {
|
||||||
"ConfigurationDb": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true",
|
"ConfigurationDb": "${SCADALINK_CONFIGURATIONDB_CONNECTION_STRING}",
|
||||||
"MachineDataDb": "Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
|
"MachineDataDb": "${SCADALINK_MACHINEDATADB_CONNECTION_STRING}"
|
||||||
},
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"LdapServer": "localhost",
|
"LdapServer": "localhost",
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
"AllowInsecureLdap": true,
|
"AllowInsecureLdap": true,
|
||||||
"LdapSearchBase": "dc=scadalink,dc=local",
|
"LdapSearchBase": "dc=scadalink,dc=local",
|
||||||
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
||||||
"LdapServiceAccountPassword": "password",
|
"LdapServiceAccountPassword": "${SCADALINK_LDAP_SERVICE_ACCOUNT_PASSWORD}",
|
||||||
"JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
"JwtSigningKey": "${SCADALINK_JWT_SIGNING_KEY}",
|
||||||
"JwtExpiryMinutes": 15,
|
"JwtExpiryMinutes": 15,
|
||||||
"IdleTimeoutMinutes": 30
|
"IdleTimeoutMinutes": 30
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadalink@localhost:8082",
|
"akka.tcp://scadalink@localhost:8082",
|
||||||
"akka.tcp://scadalink@localhost:8083"
|
"akka.tcp://scadalink@localhost:8084"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ public static class EndpointExtensions
|
|||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
|
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
|
||||||
{
|
{
|
||||||
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest);
|
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest)
|
||||||
|
// InboundAPI-006 / InboundAPI-008: active-node gating + request body
|
||||||
|
// size cap are enforced by the endpoint filter before the handler runs.
|
||||||
|
.AddEndpointFilter<InboundApiEndpointFilter>();
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +89,14 @@ public static class EndpointExtensions
|
|||||||
|
|
||||||
if (!scriptResult.Success)
|
if (!scriptResult.Success)
|
||||||
{
|
{
|
||||||
|
// InboundAPI-004: a client-aborted request is not a script failure.
|
||||||
|
// Do not pollute the failure log (reserved for genuine script errors)
|
||||||
|
// and do not attempt to write a 500 body to an already-gone connection.
|
||||||
|
if (httpContext.RequestAborted.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Results.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
// WP-5: 500 for script failures, safe error message
|
// WP-5: 500 for script failures, safe error message
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Inbound API script failure for method {Method}: {Error}",
|
"Inbound API script failure for method {Method}: {Error}",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace ScadaLink.InboundAPI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InboundAPI-008: abstraction the inbound API endpoint uses to determine whether
|
||||||
|
/// this node is the active (cluster-leader) central node.
|
||||||
|
///
|
||||||
|
/// The design states the inbound API is "Central cluster only (active node)" and
|
||||||
|
/// "fails over with it". A standby central node must not execute method scripts or
|
||||||
|
/// <c>Route.To()</c> calls — that can race the active node or run against stale
|
||||||
|
/// singleton state. <see cref="InboundApiEndpointFilter"/> consults this gate and
|
||||||
|
/// returns HTTP 503 from a standby so Traefik/clients only reach the live node.
|
||||||
|
///
|
||||||
|
/// The implementation lives in the Host (it needs Akka cluster state); when no
|
||||||
|
/// implementation is registered, the endpoint defaults to "allow" so non-clustered
|
||||||
|
/// hosts and tests are unaffected.
|
||||||
|
/// </summary>
|
||||||
|
public interface IActiveNodeGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>true</c> when this node is the active central node and may serve the
|
||||||
|
/// inbound API; <c>false</c> on a standby node.
|
||||||
|
/// </summary>
|
||||||
|
bool IsActiveNode { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoint filter applied to <c>POST /api/{methodName}</c> that enforces two
|
||||||
|
/// cross-cutting guards before the request handler runs:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// InboundAPI-008 — active-node gating. The inbound API is central-active-node-only;
|
||||||
|
/// a standby node returns HTTP 503 so it never executes method scripts.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// InboundAPI-006 — request body size cap. Oversized bodies are rejected with HTTP
|
||||||
|
/// 413 before being buffered into a <c>JsonDocument</c>.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InboundApiEndpointFilter : IEndpointFilter
|
||||||
|
{
|
||||||
|
private readonly ILogger<InboundApiEndpointFilter> _logger;
|
||||||
|
private readonly InboundApiOptions _options;
|
||||||
|
|
||||||
|
public InboundApiEndpointFilter(
|
||||||
|
ILogger<InboundApiEndpointFilter> logger,
|
||||||
|
IOptions<InboundApiOptions> options)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<object?> InvokeAsync(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next)
|
||||||
|
{
|
||||||
|
var httpContext = context.HttpContext;
|
||||||
|
|
||||||
|
// InboundAPI-008: refuse to serve the inbound API on a standby central node.
|
||||||
|
// The gate is optional — when no IActiveNodeGate is registered (non-clustered
|
||||||
|
// host / tests) the API is served, preserving prior behaviour.
|
||||||
|
var gate = httpContext.RequestServices.GetService<IActiveNodeGate>();
|
||||||
|
if (gate is { IsActiveNode: false })
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Inbound API request rejected — this node is a standby (not the active central node)");
|
||||||
|
return Results.Json(
|
||||||
|
new { error = "Inbound API is only available on the active central node" },
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboundAPI-006: cap the request body size. Reject an over-limit body up
|
||||||
|
// front via Content-Length; also lower the per-request max body size so a
|
||||||
|
// chunked/unknown-length stream is cut off by Kestrel as it is read.
|
||||||
|
var maxBytes = _options.MaxRequestBodyBytes;
|
||||||
|
if (httpContext.Request.ContentLength is { } declaredLength && declaredLength > maxBytes)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Inbound API request rejected — body length {Length} exceeds limit {Limit}",
|
||||||
|
declaredLength, maxBytes);
|
||||||
|
return Results.Json(
|
||||||
|
new { error = "Request body too large" },
|
||||||
|
statusCode: StatusCodes.Status413PayloadTooLarge);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sizeFeature = httpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||||
|
if (sizeFeature is { IsReadOnly: false })
|
||||||
|
{
|
||||||
|
sizeFeature.MaxRequestBodySize = maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,19 @@ namespace ScadaLink.InboundAPI;
|
|||||||
|
|
||||||
public class InboundApiOptions
|
public class InboundApiOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default cap on the inbound API request body, in bytes (InboundAPI-006).
|
||||||
|
/// </summary>
|
||||||
|
public const long DefaultMaxRequestBodyBytes = 1L * 1024 * 1024; // 1 MiB
|
||||||
|
|
||||||
public TimeSpan DefaultMethodTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
public TimeSpan DefaultMethodTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InboundAPI-006: maximum accepted request body size for <c>POST /api/{methodName}</c>.
|
||||||
|
/// Requests whose body exceeds this are rejected with HTTP 413 before being
|
||||||
|
/// buffered into a <see cref="System.Text.Json.JsonDocument"/>. The inbound API
|
||||||
|
/// has no rate limiting (a deliberate design choice), so an explicit, modest cap
|
||||||
|
/// bounds per-request allocations.
|
||||||
|
/// </summary>
|
||||||
|
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,11 +142,17 @@ public class InboundScriptExecutor
|
|||||||
TimeSpan timeout,
|
TimeSpan timeout,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// InboundAPI-004: keep the timeout source and the request-abort source
|
||||||
|
// separable. A single linked CTS makes a genuine client disconnect
|
||||||
|
// indistinguishable from a method timeout, so a normal disconnect would be
|
||||||
|
// logged and reported as "Script execution timed out". Use a dedicated
|
||||||
|
// timeout CTS, linked with the request token, so the two can be told apart.
|
||||||
|
using var timeoutCts = new CancellationTokenSource(timeout);
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
cancellationToken, timeoutCts.Token);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
cts.CancelAfter(timeout);
|
|
||||||
|
|
||||||
var context = new InboundScriptContext(parameters, route, cts.Token);
|
var context = new InboundScriptContext(parameters, route, cts.Token);
|
||||||
|
|
||||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||||
@@ -169,10 +175,20 @@ public class InboundScriptExecutor
|
|||||||
return new InboundScriptResult(true, resultJson, null);
|
return new InboundScriptResult(true, resultJson, null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// InboundAPI-004: distinguish a genuine method timeout from a client
|
||||||
|
// abort. Only the timeout CTS firing is a real timeout; if the caller's
|
||||||
|
// request token fired, the client disconnected — do not pollute the
|
||||||
|
// timeout log (reserved for genuine script-execution timeouts).
|
||||||
|
if (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
|
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
|
||||||
return new InboundScriptResult(false, null, "Script execution timed out");
|
return new InboundScriptResult(false, null, "Script execution timed out");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name);
|
||||||
|
return new InboundScriptResult(false, null, "Request cancelled by client");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
|
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<InboundScriptExecutor>();
|
services.AddSingleton<InboundScriptExecutor>();
|
||||||
services.AddScoped<RouteHelper>();
|
services.AddScoped<RouteHelper>();
|
||||||
|
|
||||||
|
// InboundAPI-006 / InboundAPI-008: endpoint filter enforcing the request
|
||||||
|
// body size cap and active-node gating for POST /api/{methodName}.
|
||||||
|
services.AddSingleton<InboundApiEndpointFilter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
@@ -42,6 +43,25 @@ public class ManagementActor : ReceiveActor
|
|||||||
Receive<ManagementEnvelope>(HandleEnvelope);
|
Receive<ManagementEnvelope>(HandleEnvelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializer settings for command results. <see cref="ReferenceHandler.IgnoreCycles"/>
|
||||||
|
/// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
|
||||||
|
/// camelCase matches what the CLI / HTTP layer expect (finding ManagementService-007).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly JsonSerializerOptions ResultSerializerOptions = new()
|
||||||
|
{
|
||||||
|
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a command result to JSON using <see cref="System.Text.Json"/> — the same
|
||||||
|
/// serializer the HTTP endpoint uses — with cycle-safe settings.
|
||||||
|
/// </summary>
|
||||||
|
public static string SerializeResult(object? result) =>
|
||||||
|
JsonSerializer.Serialize(result, ResultSerializerOptions);
|
||||||
|
|
||||||
private void HandleEnvelope(ManagementEnvelope envelope)
|
private void HandleEnvelope(ManagementEnvelope envelope)
|
||||||
{
|
{
|
||||||
var sender = Sender;
|
var sender = Sender;
|
||||||
@@ -57,27 +77,42 @@ public class ManagementActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process command asynchronously with scoped DI
|
// Process the command and pipe the mapped result back to the captured sender.
|
||||||
Task.Run(async () =>
|
// PipeTo (rather than Task.Run + Tell-from-continuation) is the project's Akka.NET
|
||||||
|
// convention: faults are mapped in the failure continuation, no actor state is
|
||||||
|
// captured in the closure, and synchronous faults in command setup are still mapped.
|
||||||
|
ProcessCommand(envelope, user)
|
||||||
|
.PipeTo(sender,
|
||||||
|
success: result => result,
|
||||||
|
failure: ex => MapFault(ex, correlationId, envelope.Command));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a command on a scoped service provider and maps the result to a management
|
||||||
|
/// response message. Returns a faulted task on error so the PipeTo failure
|
||||||
|
/// continuation maps it uniformly.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
|
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
|
||||||
var json = JsonConvert.SerializeObject(result, Formatting.None);
|
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||||||
sender.Tell(new ManagementSuccess(correlationId, json));
|
|
||||||
}
|
}
|
||||||
catch (SiteScopeViolationException ex)
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps an exception from command processing to the appropriate management response.
|
||||||
|
/// </summary>
|
||||||
|
private object MapFault(Exception ex, string correlationId, object command)
|
||||||
{
|
{
|
||||||
sender.Tell(new ManagementUnauthorized(correlationId, ex.Message));
|
// PipeTo wraps continuation exceptions; unwrap to find the real cause.
|
||||||
}
|
var cause = ex is AggregateException agg ? agg.Flatten().InnerException ?? ex : ex;
|
||||||
catch (Exception ex)
|
|
||||||
{
|
if (cause is SiteScopeViolationException scope)
|
||||||
_logger.LogError(ex, "Management command {Command} failed (CorrelationId={CorrelationId})",
|
return new ManagementUnauthorized(correlationId, scope.Message);
|
||||||
envelope.Command.GetType().Name, correlationId);
|
|
||||||
sender.Tell(new ManagementError(correlationId, ex.Message, "COMMAND_FAILED"));
|
_logger.LogError(cause, "Management command {Command} failed (CorrelationId={CorrelationId})",
|
||||||
}
|
command.GetType().Name, correlationId);
|
||||||
});
|
return new ManagementError(correlationId, cause.Message, "COMMAND_FAILED");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetRequiredRole(object command) => command switch
|
private static string? GetRequiredRole(object command) => command switch
|
||||||
@@ -345,8 +380,23 @@ public class ManagementActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper to log an audit entry after a successful mutation.
|
/// Logs an audit entry after a successful mutation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Audit-logging contract (finding ManagementService-009). Every mutating operation is
|
||||||
|
/// audited exactly once, by whichever layer owns the write:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Handlers that mutate a repository directly (site, area, data-connection,
|
||||||
|
/// external-system, notification, security, API-key, scope-rule) call this helper
|
||||||
|
/// explicitly after the successful change.</item>
|
||||||
|
/// <item>Handlers that delegate to a domain service (<c>TemplateService</c>,
|
||||||
|
/// <c>SharedScriptService</c>, <c>InstanceService</c>, <c>AreaService</c>,
|
||||||
|
/// <c>SiteService</c>, <c>TemplateFolderService</c>, <c>DeploymentService</c>,
|
||||||
|
/// <c>ArtifactDeploymentService</c>) do NOT call this helper — those services own their
|
||||||
|
/// own <see cref="IAuditService"/> dependency and audit internally. Adding an explicit
|
||||||
|
/// <see cref="AuditAsync"/> call in such a handler would double-log.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
|
private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
|
||||||
{
|
{
|
||||||
var auditService = sp.GetRequiredService<IAuditService>();
|
var auditService = sp.GetRequiredService<IAuditService>();
|
||||||
|
|||||||
@@ -77,46 +77,19 @@ public static class ManagementEndpoints
|
|||||||
permittedSiteIds);
|
permittedSiteIds);
|
||||||
|
|
||||||
// 4. Parse command from request body
|
// 4. Parse command from request body
|
||||||
JsonDocument doc;
|
string body;
|
||||||
try
|
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
|
||||||
{
|
{
|
||||||
doc = await JsonDocument.ParseAsync(context.Request.Body);
|
body = await reader.ReadToEndAsync();
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return Results.Json(new { error = "Invalid JSON body.", code = "BAD_REQUEST" }, statusCode: 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
|
var parse = ParseCommand(body);
|
||||||
|
if (!parse.Success)
|
||||||
{
|
{
|
||||||
return Results.Json(new { error = "Missing 'command' field.", code = "BAD_REQUEST" }, statusCode: 400);
|
return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
var commandName = commandNameElement.GetString();
|
var command = parse.Command!;
|
||||||
if (string.IsNullOrWhiteSpace(commandName))
|
|
||||||
{
|
|
||||||
return Results.Json(new { error = "Empty 'command' field.", code = "BAD_REQUEST" }, statusCode: 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
var commandType = ManagementCommandRegistry.Resolve(commandName);
|
|
||||||
if (commandType == null)
|
|
||||||
{
|
|
||||||
return Results.Json(new { error = $"Unknown command: '{commandName}'.", code = "BAD_REQUEST" }, statusCode: 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
object command;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payloadElement = doc.RootElement.TryGetProperty("payload", out var p)
|
|
||||||
? p
|
|
||||||
: JsonDocument.Parse("{}").RootElement;
|
|
||||||
command = JsonSerializer.Deserialize(payloadElement.GetRawText(), commandType,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Results.Json(new { error = $"Failed to deserialize payload: {ex.Message}", code = "BAD_REQUEST" }, statusCode: 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Dispatch to ManagementActor
|
// 5. Dispatch to ManagementActor
|
||||||
var holder = context.RequestServices.GetRequiredService<ManagementActorHolder>();
|
var holder = context.RequestServices.GetRequiredService<ManagementActorHolder>();
|
||||||
@@ -148,4 +121,68 @@ public static class ManagementEndpoints
|
|||||||
_ => Results.Json(new { error = "Unexpected response.", code = "INTERNAL_ERROR" }, statusCode: 500)
|
_ => Results.Json(new { error = "Unexpected response.", code = "INTERNAL_ERROR" }, statusCode: 500)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of parsing a management request body into a strongly-typed command.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct CommandParseResult(
|
||||||
|
bool Success, object? Command, string? ErrorMessage, string? ErrorCode)
|
||||||
|
{
|
||||||
|
public static CommandParseResult Ok(object command) => new(true, command, null, null);
|
||||||
|
public static CommandParseResult Fail(string message) => new(false, null, message, "BAD_REQUEST");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a management request body — a JSON object with a <c>command</c> name and an
|
||||||
|
/// optional <c>payload</c> — into the strongly-typed command record. The parsed
|
||||||
|
/// <see cref="JsonDocument"/> is disposed deterministically and the missing-payload
|
||||||
|
/// case does not allocate a throwaway document (finding ManagementService-006).
|
||||||
|
/// </summary>
|
||||||
|
public static CommandParseResult ParseCommand(string body)
|
||||||
|
{
|
||||||
|
using JsonDocument doc = ParseDocument(body, out var parseError);
|
||||||
|
if (parseError != null)
|
||||||
|
return CommandParseResult.Fail(parseError);
|
||||||
|
|
||||||
|
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
|
||||||
|
return CommandParseResult.Fail("Missing 'command' field.");
|
||||||
|
|
||||||
|
var commandName = commandNameElement.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(commandName))
|
||||||
|
return CommandParseResult.Fail("Empty 'command' field.");
|
||||||
|
|
||||||
|
var commandType = ManagementCommandRegistry.Resolve(commandName);
|
||||||
|
if (commandType == null)
|
||||||
|
return CommandParseResult.Fail($"Unknown command: '{commandName}'.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Missing payload: deserialize from the empty-object literal rather than
|
||||||
|
// allocating (and leaking) a throwaway JsonDocument.
|
||||||
|
var payloadJson = doc.RootElement.TryGetProperty("payload", out var p)
|
||||||
|
? p.GetRawText()
|
||||||
|
: "{}";
|
||||||
|
var command = JsonSerializer.Deserialize(payloadJson, commandType,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||||
|
return CommandParseResult.Ok(command);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return CommandParseResult.Fail($"Failed to deserialize payload: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonDocument ParseDocument(string body, out string? error)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
return JsonDocument.Parse(body);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
error = "Invalid JSON body.";
|
||||||
|
return JsonDocument.Parse("{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace ScadaLink.NotificationService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-009: Scrubs SMTP credential secrets out of free text (typically exception
|
||||||
|
/// messages echoed back by an SMTP server) before that text is written to a log.
|
||||||
|
/// MailKit authentication exceptions can contain server responses that quote the
|
||||||
|
/// supplied credentials; this prevents a password, client secret, or OAuth2 token
|
||||||
|
/// from leaking into the operational logs.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CredentialRedactor
|
||||||
|
{
|
||||||
|
private const string Mask = "***REDACTED***";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <paramref name="text"/> with every secret component of the supplied
|
||||||
|
/// colon-delimited credential string masked.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The text to scrub (e.g. an exception message).</param>
|
||||||
|
/// <param name="credentials">
|
||||||
|
/// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2
|
||||||
|
/// <c>tenantId:clientId:clientSecret</c>. May be null.
|
||||||
|
/// </param>
|
||||||
|
public static string Scrub(string? text, string? credentials)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
|
||||||
|
{
|
||||||
|
return text ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = text;
|
||||||
|
|
||||||
|
// Mask each individual colon-delimited component (covers user, password,
|
||||||
|
// tenant, clientId, clientSecret) and the whole packed string. Order longest
|
||||||
|
// first so a component that is a substring of another is still fully masked.
|
||||||
|
var parts = credentials.Split(':')
|
||||||
|
.Where(p => p.Length >= 4)
|
||||||
|
.Append(credentials)
|
||||||
|
.Distinct()
|
||||||
|
.OrderByDescending(p => p.Length);
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
result = result.Replace(part, Mask, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,24 @@ namespace ScadaLink.NotificationService;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISmtpClientWrapper
|
public interface ISmtpClientWrapper
|
||||||
{
|
{
|
||||||
Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default);
|
/// <summary>
|
||||||
|
/// Connects to the SMTP server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tlsMode">
|
||||||
|
/// NS-005: explicit three-state TLS mode (None/StartTls/Ssl) — replaces the old
|
||||||
|
/// <c>bool useTls</c> which could not represent implicit-SSL and silently fell
|
||||||
|
/// back to opportunistic negotiation for non-StartTLS configurations.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="connectionTimeoutSeconds">
|
||||||
|
/// NS-007: SMTP connection/operation timeout in seconds. A non-positive value
|
||||||
|
/// leaves the client's default timeout in place.
|
||||||
|
/// </param>
|
||||||
|
Task ConnectAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
SmtpTlsMode tlsMode,
|
||||||
|
int connectionTimeoutSeconds,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default);
|
Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default);
|
||||||
Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default);
|
Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default);
|
||||||
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -13,9 +13,33 @@ public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly SmtpClient _client = new();
|
private readonly SmtpClient _client = new();
|
||||||
|
|
||||||
public async Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default)
|
public async Task ConnectAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
SmtpTlsMode tlsMode,
|
||||||
|
int connectionTimeoutSeconds,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var secureSocket = useTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
// NS-005: map the explicit three-state TLS mode onto MailKit's socket
|
||||||
|
// options. The old code collapsed everything to a boolean and used
|
||||||
|
// SecureSocketOptions.Auto for the non-StartTLS case, which let MailKit
|
||||||
|
// opportunistically negotiate TLS even when "None" was configured and
|
||||||
|
// gave SSL-on-connect no representation at all.
|
||||||
|
var secureSocket = tlsMode switch
|
||||||
|
{
|
||||||
|
SmtpTlsMode.None => SecureSocketOptions.None,
|
||||||
|
SmtpTlsMode.StartTls => SecureSocketOptions.StartTls,
|
||||||
|
SmtpTlsMode.Ssl => SecureSocketOptions.SslOnConnect,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(tlsMode), tlsMode, "Unknown TLS mode."),
|
||||||
|
};
|
||||||
|
|
||||||
|
// NS-007: honour the configured connection timeout. SmtpClient.Timeout is
|
||||||
|
// in milliseconds and applies to connect/auth/send operations.
|
||||||
|
if (connectionTimeoutSeconds > 0)
|
||||||
|
{
|
||||||
|
_client.Timeout = connectionTimeoutSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
await _client.ConnectAsync(host, port, secureSocket, cancellationToken);
|
await _client.ConnectAsync(host, port, secureSocket, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
|||||||
using MailKit;
|
using MailKit;
|
||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MimeKit;
|
||||||
using ScadaLink.Commons.Entities.Notifications;
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
@@ -68,6 +69,30 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
return new NotificationResult(false, "No SMTP configuration available");
|
return new NotificationResult(false, "No SMTP configuration available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NS-005: validate the configured TLS mode up front — an unknown value is a
|
||||||
|
// configuration error and must surface as a clean result, not a silent
|
||||||
|
// fallback to opportunistic TLS negotiation.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError("Invalid SMTP TLS mode for list {List}: {Reason}", listName, ex.Message);
|
||||||
|
return new NotificationResult(false, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NS-008: validate every email address before attempting delivery. A single
|
||||||
|
// malformed address previously caused MailboxAddress.Parse to throw a
|
||||||
|
// ParseException that escaped SendAsync unhandled; it must instead produce a
|
||||||
|
// clean NotificationResult the calling script can handle.
|
||||||
|
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
||||||
|
if (addressError != null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Notification to list {List} has invalid addresses: {Reason}", listName, addressError);
|
||||||
|
return new NotificationResult(false, addressError);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken);
|
await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken);
|
||||||
@@ -75,9 +100,13 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
}
|
}
|
||||||
catch (SmtpPermanentException ex)
|
catch (SmtpPermanentException ex)
|
||||||
{
|
{
|
||||||
// WP-12: Permanent SMTP failure — returned to script
|
// WP-12: Permanent SMTP failure — returned to script.
|
||||||
_logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName);
|
// NS-009: scrub credential fragments out of the server-supplied message
|
||||||
return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}");
|
// before logging or returning it.
|
||||||
|
var detail = CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials);
|
||||||
|
_logger.LogError(
|
||||||
|
"Permanent SMTP failure sending to list {List}: {Detail}", listName, detail);
|
||||||
|
return new NotificationResult(false, $"Permanent SMTP error: {detail}");
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -86,8 +115,11 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
}
|
}
|
||||||
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
|
catch (Exception ex) when (IsTransientSmtpError(ex, cancellationToken))
|
||||||
{
|
{
|
||||||
// WP-12: Transient SMTP failure — hand to S&F
|
// WP-12: Transient SMTP failure — hand to S&F.
|
||||||
_logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName);
|
// NS-009: scrub credential fragments before logging.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Transient SMTP failure sending to list {List} ({ExceptionType}): {Detail}; buffering for retry",
|
||||||
|
listName, ex.GetType().Name, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
|
||||||
|
|
||||||
if (_storeAndForward == null)
|
if (_storeAndForward == null)
|
||||||
{
|
{
|
||||||
@@ -155,6 +187,30 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NS-005: an unknown TLS mode is a configuration error that retrying cannot
|
||||||
|
// fix — park the buffered message rather than throwing on every sweep.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SmtpTlsModeParser.Parse(smtpConfig.TlsMode);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Buffered notification to list '{List}' cannot be delivered — {Reason}; parking.",
|
||||||
|
payload.ListName, ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NS-008: a malformed address cannot be fixed by retrying — park it.
|
||||||
|
var addressError = ValidateAddresses(smtpConfig.FromAddress, recipients);
|
||||||
|
if (addressError != null)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Buffered notification to list '{List}' has invalid addresses ({Reason}); parking.",
|
||||||
|
payload.ListName, addressError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DeliverAsync(smtpConfig, recipients, payload.Subject, payload.Message, cancellationToken);
|
await DeliverAsync(smtpConfig, recipients, payload.Subject, payload.Message, cancellationToken);
|
||||||
@@ -162,7 +218,10 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
}
|
}
|
||||||
catch (SmtpPermanentException ex)
|
catch (SmtpPermanentException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Buffered notification to list '{List}' failed permanently; parking.", payload.ListName);
|
// NS-009: scrub credential fragments out of the message before logging.
|
||||||
|
_logger.LogError(
|
||||||
|
"Buffered notification to list '{List}' failed permanently ({Detail}); parking.",
|
||||||
|
payload.ListName, CredentialRedactor.Scrub(ex.Message, smtpConfig.Credentials));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries.
|
// Transient SMTP errors propagate out of DeliverAsync — the S&F engine retries.
|
||||||
@@ -171,7 +230,55 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
private sealed record BufferedNotification(string ListName, string Subject, string Message);
|
private sealed record BufferedNotification(string ListName, string Subject, string Message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delivers an email via SMTP. Throws on failure.
|
/// NS-007: throttles concurrent SMTP deliveries to the configured
|
||||||
|
/// <c>MaxConcurrentConnections</c>. Created lazily from the first SMTP config
|
||||||
|
/// seen (one SMTP config is deployed per site, so the limit is stable).
|
||||||
|
/// </summary>
|
||||||
|
private SemaphoreSlim? _concurrencyLimiter;
|
||||||
|
private readonly object _limiterLock = new();
|
||||||
|
|
||||||
|
private SemaphoreSlim GetConcurrencyLimiter(SmtpConfiguration config)
|
||||||
|
{
|
||||||
|
if (_concurrencyLimiter != null)
|
||||||
|
{
|
||||||
|
return _concurrencyLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_limiterLock)
|
||||||
|
{
|
||||||
|
// NS-007: a non-positive configured value would make SemaphoreSlim
|
||||||
|
// throw; fall back to the design-doc default of 5.
|
||||||
|
var max = config.MaxConcurrentConnections > 0 ? config.MaxConcurrentConnections : 5;
|
||||||
|
_concurrencyLimiter ??= new SemaphoreSlim(max, max);
|
||||||
|
return _concurrencyLimiter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-008: Validates the sender and recipient email addresses, returning a
|
||||||
|
/// human-readable error string if any is malformed, or null if all parse.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? ValidateAddresses(
|
||||||
|
string fromAddress, IReadOnlyList<NotificationRecipient> recipients)
|
||||||
|
{
|
||||||
|
if (!MailboxAddress.TryParse(fromAddress, out _))
|
||||||
|
{
|
||||||
|
return $"Invalid sender (from) email address: '{fromAddress}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
var invalid = recipients
|
||||||
|
.Where(r => !MailboxAddress.TryParse(r.EmailAddress, out _))
|
||||||
|
.Select(r => r.EmailAddress)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return invalid.Count > 0
|
||||||
|
? $"Invalid recipient email address(es): {string.Join(", ", invalid)}"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delivers an email via SMTP. Throws on failure (transient errors and
|
||||||
|
/// <see cref="SmtpPermanentException"/> propagate; the caller classifies them).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal async Task DeliverAsync(
|
internal async Task DeliverAsync(
|
||||||
SmtpConfiguration config,
|
SmtpConfiguration config,
|
||||||
@@ -180,16 +287,23 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
string body,
|
string body,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var tlsMode = SmtpTlsModeParser.Parse(config.TlsMode);
|
||||||
|
|
||||||
|
// NS-007: bound the number of concurrent SMTP connections per site.
|
||||||
|
var limiter = GetConcurrencyLimiter(config);
|
||||||
|
await limiter.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
// NS-004: create exactly one client and dispose the one actually used.
|
// NS-004: create exactly one client and dispose the one actually used.
|
||||||
var smtp = _smtpClientFactory();
|
var smtp = _smtpClientFactory();
|
||||||
using var disposable = smtp as IDisposable;
|
using var disposable = smtp as IDisposable;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var useTls = config.TlsMode?.Equals("starttls", StringComparison.OrdinalIgnoreCase) == true;
|
// NS-005/NS-007: explicit TLS mode and the configured connection timeout.
|
||||||
await smtp.ConnectAsync(config.Host, config.Port, useTls, cancellationToken);
|
await smtp.ConnectAsync(
|
||||||
|
config.Host, config.Port, tlsMode, config.ConnectionTimeoutSeconds, cancellationToken);
|
||||||
|
|
||||||
// Resolve credentials (OAuth2 token refresh if needed)
|
// Resolve credentials (OAuth2 token fetched/cached by the token service).
|
||||||
var credentials = config.Credentials;
|
var credentials = config.Credentials;
|
||||||
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase) && _tokenService != null && credentials != null)
|
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase) && _tokenService != null && credentials != null)
|
||||||
{
|
{
|
||||||
@@ -218,6 +332,11 @@ public class NotificationDeliveryService : INotificationDeliveryService
|
|||||||
}
|
}
|
||||||
// Transient and SmtpPermanentException both propagate unchanged: SendAsync's
|
// Transient and SmtpPermanentException both propagate unchanged: SendAsync's
|
||||||
// catch filters (SmtpPermanentException / IsTransientSmtpError) handle them.
|
// catch filters (SmtpPermanentException / IsTransientSmtpError) handle them.
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// NS-007: always release the concurrency slot, even on failure.
|
||||||
|
limiter.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SmtpErrorClass
|
private enum SmtpErrorClass
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -6,14 +9,18 @@ namespace ScadaLink.NotificationService;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-11: OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry.
|
/// WP-11: OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry.
|
||||||
/// Used for Microsoft 365 SMTP authentication.
|
/// Used for Microsoft 365 SMTP authentication.
|
||||||
|
/// NS-006: tokens are cached per credential identity (tenant/client/secret), so a
|
||||||
|
/// second SMTP configuration with different credentials never receives the first
|
||||||
|
/// configuration's token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class OAuth2TokenService
|
public class OAuth2TokenService
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ILogger<OAuth2TokenService> _logger;
|
private readonly ILogger<OAuth2TokenService> _logger;
|
||||||
private string? _cachedToken;
|
|
||||||
private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue;
|
// NS-006: cache keyed by a hash of the credential string. Each distinct
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
// tenant/client/secret triple gets its own cached token and its own lock.
|
||||||
|
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||||
|
|
||||||
public OAuth2TokenService(
|
public OAuth2TokenService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -29,18 +36,21 @@ public class OAuth2TokenService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> GetTokenAsync(string credentials, CancellationToken cancellationToken = default)
|
public async Task<string> GetTokenAsync(string credentials, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry)
|
var key = CredentialKey(credentials);
|
||||||
|
var entry = _cache.GetOrAdd(key, _ => new CacheEntry());
|
||||||
|
|
||||||
|
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
|
||||||
{
|
{
|
||||||
return _cachedToken;
|
return entry.Token;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _lock.WaitAsync(cancellationToken);
|
await entry.Lock.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Double-check after acquiring lock
|
// Double-check after acquiring the per-credential lock.
|
||||||
if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry)
|
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
|
||||||
{
|
{
|
||||||
return _cachedToken;
|
return entry.Token;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts = credentials.Split(':', 3);
|
var parts = credentials.Split(':', 3);
|
||||||
@@ -70,18 +80,39 @@ public class OAuth2TokenService
|
|||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
_cachedToken = doc.RootElement.GetProperty("access_token").GetString()
|
var token = doc.RootElement.GetProperty("access_token").GetString()
|
||||||
?? throw new InvalidOperationException("No access_token in OAuth2 response");
|
?? throw new InvalidOperationException("No access_token in OAuth2 response");
|
||||||
|
|
||||||
var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
|
var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
|
||||||
_tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
|
entry.Token = token;
|
||||||
|
entry.Expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
|
||||||
|
|
||||||
_logger.LogInformation("OAuth2 token refreshed, expires in {ExpiresIn}s", expiresIn);
|
// NS-009: the token endpoint identity is logged by tenant only — never
|
||||||
return _cachedToken;
|
// the client secret or the access token itself.
|
||||||
|
_logger.LogInformation(
|
||||||
|
"OAuth2 token refreshed for tenant {Tenant}, expires in {ExpiresIn}s", tenantId, expiresIn);
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_lock.Release();
|
entry.Lock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-006: a stable, non-reversible key for the credential string so the cache
|
||||||
|
/// is partitioned by credential identity without holding the secret as a key.
|
||||||
|
/// </summary>
|
||||||
|
private static string CredentialKey(string credentials)
|
||||||
|
{
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(credentials));
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CacheEntry
|
||||||
|
{
|
||||||
|
public string? Token;
|
||||||
|
public DateTimeOffset Expiry = DateTimeOffset.MinValue;
|
||||||
|
public readonly SemaphoreSlim Lock = new(1, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace ScadaLink.NotificationService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-005: The three TLS modes the design doc defines for SMTP connections.
|
||||||
|
/// A single boolean cannot represent the requirement, so the configured
|
||||||
|
/// <c>SmtpConfiguration.TlsMode</c> string is parsed into this three-state enum.
|
||||||
|
/// </summary>
|
||||||
|
public enum SmtpTlsMode
|
||||||
|
{
|
||||||
|
/// <summary>No transport security — plain SMTP. Maps to <c>SecureSocketOptions.None</c>.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>Opportunistic STARTTLS upgrade (typically port 587). Maps to <c>SecureSocketOptions.StartTls</c>.</summary>
|
||||||
|
StartTls,
|
||||||
|
|
||||||
|
/// <summary>Implicit TLS / SSL-on-connect (typically port 465). Maps to <c>SecureSocketOptions.SslOnConnect</c>.</summary>
|
||||||
|
Ssl,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-005: Parses the free-text <c>SmtpConfiguration.TlsMode</c> value into a
|
||||||
|
/// <see cref="SmtpTlsMode"/>, rejecting unknown values rather than silently
|
||||||
|
/// falling back to opportunistic negotiation.
|
||||||
|
/// </summary>
|
||||||
|
public static class SmtpTlsModeParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a configured TLS mode string. A null or empty value defaults to
|
||||||
|
/// <see cref="SmtpTlsMode.StartTls"/> (the design-doc default for port 587).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">The value is not one of None/StartTLS/SSL.</exception>
|
||||||
|
public static SmtpTlsMode Parse(string? tlsMode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tlsMode))
|
||||||
|
{
|
||||||
|
return SmtpTlsMode.StartTls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tlsMode.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"none" => SmtpTlsMode.None,
|
||||||
|
"starttls" => SmtpTlsMode.StartTls,
|
||||||
|
"ssl" => SmtpTlsMode.Ssl,
|
||||||
|
_ => throw new ArgumentException(
|
||||||
|
$"Unknown SMTP TLS mode '{tlsMode}'. Expected one of: None, StartTLS, SSL.",
|
||||||
|
nameof(tlsMode)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ public class JwtTokenService
|
|||||||
public const string SiteIdClaimType = "SiteId";
|
public const string SiteIdClaimType = "SiteId";
|
||||||
public const string LastActivityClaimType = "LastActivity";
|
public const string LastActivityClaimType = "LastActivity";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixed issuer bound into every token and required on validation. Binding
|
||||||
|
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
|
||||||
|
/// between the two central nodes, accidental reuse of the same secret for an
|
||||||
|
/// unrelated internal token would otherwise be silently exploitable.
|
||||||
|
/// </summary>
|
||||||
|
public const string TokenIssuer = "scadalink-central";
|
||||||
|
|
||||||
|
/// <summary>Fixed audience bound into every token and required on validation.</summary>
|
||||||
|
public const string TokenAudience = "scadalink-central";
|
||||||
|
|
||||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
||||||
{
|
{
|
||||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||||
@@ -37,11 +48,19 @@ public class JwtTokenService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
|
||||||
|
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
|
||||||
|
/// refresh the caller MUST pass the existing anchor forward so the idle window
|
||||||
|
/// continues to be measured from the user's last genuine activity rather than
|
||||||
|
/// from token issuance time.
|
||||||
|
/// </summary>
|
||||||
public string GenerateToken(
|
public string GenerateToken(
|
||||||
string displayName,
|
string displayName,
|
||||||
string username,
|
string username,
|
||||||
IReadOnlyList<string> roles,
|
IReadOnlyList<string> roles,
|
||||||
IReadOnlyList<string>? permittedSiteIds)
|
IReadOnlyList<string>? permittedSiteIds,
|
||||||
|
DateTimeOffset? lastActivity = null)
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
@@ -50,7 +69,7 @@ public class JwtTokenService
|
|||||||
{
|
{
|
||||||
new(DisplayNameClaimType, displayName),
|
new(DisplayNameClaimType, displayName),
|
||||||
new(UsernameClaimType, username),
|
new(UsernameClaimType, username),
|
||||||
new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o"))
|
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var role in roles)
|
foreach (var role in roles)
|
||||||
@@ -67,6 +86,8 @@ public class JwtTokenService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: TokenIssuer,
|
||||||
|
audience: TokenAudience,
|
||||||
claims: claims,
|
claims: claims,
|
||||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||||
signingCredentials: credentials);
|
signingCredentials: credentials);
|
||||||
@@ -79,8 +100,10 @@ public class JwtTokenService
|
|||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||||
var validationParameters = new TokenValidationParameters
|
var validationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = false,
|
ValidateIssuer = true,
|
||||||
ValidateAudience = false,
|
ValidIssuer = TokenIssuer,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = TokenAudience,
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = key,
|
IssuerSigningKey = key,
|
||||||
@@ -121,6 +144,15 @@ public class JwtTokenService
|
|||||||
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
|
||||||
|
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
|
||||||
|
/// triggered by a request, but it must not be treated as user activity — the
|
||||||
|
/// idle window must keep being measured from the user's last genuine interaction,
|
||||||
|
/// otherwise the documented 30-minute idle timeout could never fire for a client
|
||||||
|
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
|
||||||
|
/// anchor when handling a genuine user request.
|
||||||
|
/// </summary>
|
||||||
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||||
{
|
{
|
||||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||||
@@ -132,6 +164,36 @@ public class JwtTokenService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds);
|
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||||
|
ReadLastActivity(currentPrincipal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
|
||||||
|
/// advanced to now. This is the explicit "user did something" path — distinct
|
||||||
|
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
|
||||||
|
/// handling a genuine user interaction.
|
||||||
|
/// </summary>
|
||||||
|
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||||
|
{
|
||||||
|
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||||
|
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||||
|
|
||||||
|
if (displayName == null || username == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var claim = principal.FindFirst(LastActivityClaimType);
|
||||||
|
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public class LdapAuthService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
|
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
|
||||||
var searchResults = await Task.Run(() =>
|
var searchResults = await Task.Run(() =>
|
||||||
connection.Search(
|
connection.Search(
|
||||||
_options.LdapSearchBase,
|
_options.LdapSearchBase,
|
||||||
@@ -133,17 +133,13 @@ public class LdapAuthService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
|
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// If username already looks like a DN, use it as-is
|
|
||||||
if (username.Contains('='))
|
|
||||||
return username;
|
|
||||||
|
|
||||||
// If a service account is configured, search for the user's actual DN
|
// If a service account is configured, search for the user's actual DN
|
||||||
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
||||||
{
|
{
|
||||||
await Task.Run(() =>
|
await Task.Run(() =>
|
||||||
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
|
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
|
||||||
|
|
||||||
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
|
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
|
||||||
var searchResults = await Task.Run(() =>
|
var searchResults = await Task.Run(() =>
|
||||||
connection.Search(
|
connection.Search(
|
||||||
_options.LdapSearchBase,
|
_options.LdapSearchBase,
|
||||||
@@ -158,13 +154,68 @@ public class LdapAuthService
|
|||||||
return entry.Dn;
|
return entry.Dn;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}");
|
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||||
|
$"No entry found for {_options.LdapUserIdAttribute}={username}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: construct DN directly
|
// Fallback: construct the bind DN directly from the configured user-id
|
||||||
return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
|
// attribute. The username is RFC 4514 DN-escaped so it cannot alter the
|
||||||
? $"cn={username}"
|
// DN structure (Security-005). The previous Contains('=') shortcut that
|
||||||
: $"cn={username},{_options.LdapSearchBase}";
|
// accepted a raw caller-supplied DN has been removed — accepting an
|
||||||
|
// arbitrary DN from untrusted input let a client choose the bind identity.
|
||||||
|
return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the no-service-account fallback bind DN as
|
||||||
|
/// <c>{userIdAttribute}={escaped-username}[,{searchBase}]</c>. The username is
|
||||||
|
/// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject
|
||||||
|
/// additional RDN components or change the bind identity.
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute)
|
||||||
|
{
|
||||||
|
var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}";
|
||||||
|
return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escapes a string for use as an RFC 4514 DN attribute value: the special
|
||||||
|
/// characters <c>, + " \ < > ;</c> are backslash-escaped, as are a leading
|
||||||
|
/// or trailing space and a leading <c>#</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string EscapeLdapDn(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input))
|
||||||
|
return input;
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder(input.Length + 8);
|
||||||
|
for (var i = 0; i < input.Length; i++)
|
||||||
|
{
|
||||||
|
var c = input[i];
|
||||||
|
var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1);
|
||||||
|
var isLeadingHash = c == '#' && i == 0;
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case ',':
|
||||||
|
case '+':
|
||||||
|
case '"':
|
||||||
|
case '\\':
|
||||||
|
case '<':
|
||||||
|
case '>':
|
||||||
|
case ';':
|
||||||
|
sb.Append('\\').Append(c);
|
||||||
|
break;
|
||||||
|
case '\0':
|
||||||
|
sb.Append("\\00");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (isEdgeSpace || isLeadingHash)
|
||||||
|
sb.Append('\\');
|
||||||
|
sb.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EscapeLdapFilter(string input)
|
private static string EscapeLdapFilter(string input)
|
||||||
|
|||||||
@@ -37,10 +37,19 @@ public class SecurityOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
|
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
|
||||||
/// Required for search-then-bind authentication. If empty, direct bind with
|
/// Required for search-then-bind authentication. If empty, direct bind with
|
||||||
/// cn={username},{LdapSearchBase} is attempted instead.
|
/// {LdapUserIdAttribute}={username},{LdapSearchBase} is attempted instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string LdapServiceAccountDn { get; set; } = string.Empty;
|
public string LdapServiceAccountDn { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP attribute that identifies a user. Used both for the search-then-bind
|
||||||
|
/// filter (<c>({LdapUserIdAttribute}={username})</c>) and for constructing the
|
||||||
|
/// fallback bind DN when no service account is configured, so the two
|
||||||
|
/// authentication modes are interchangeable. Common values: <c>uid</c> (OpenLDAP),
|
||||||
|
/// <c>sAMAccountName</c> (Active Directory).
|
||||||
|
/// </summary>
|
||||||
|
public string LdapUserIdAttribute { get; set; } = "uid";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service account password for LDAP user searches.
|
/// Service account password for LDAP user searches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -5,15 +5,32 @@ using Microsoft.Extensions.Options;
|
|||||||
namespace ScadaLink.DeploymentManager.Tests;
|
namespace ScadaLink.DeploymentManager.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DeploymentManager-008: DeploymentManagerOptions must be bound to the
|
/// DeploymentManager-008: DeploymentManagerOptions must be resolvable via the
|
||||||
/// "ScadaLink:DeploymentManager" configuration section, consistent with the
|
/// Options pattern and bindable to the "ScadaLink:DeploymentManager"
|
||||||
/// Options-pattern convention used by the other components.
|
/// configuration section. The component itself does not depend on
|
||||||
|
/// IConfiguration (enforced by Host's OptionsTests) — the Host binds the
|
||||||
|
/// section; AddDeploymentManager only guarantees IOptions resolvability.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ServiceCollectionExtensionsTests
|
public class ServiceCollectionExtensionsTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddDeploymentManager_WithConfiguration_BindsDeploymentManagerOptions()
|
public void AddDeploymentManager_RegistersResolvableOptions_WithDefaults()
|
||||||
{
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDeploymentManager();
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var options = provider.GetRequiredService<IOptions<DeploymentManagerOptions>>().Value;
|
||||||
|
|
||||||
|
// No section bound -> the option-class defaults are retained.
|
||||||
|
Assert.Equal(TimeSpan.FromSeconds(5), options.OperationLockTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddDeploymentManager_OptionsBindToConfigurationSection_AsTheHostWires()
|
||||||
|
{
|
||||||
|
// Mirrors the Host wiring: the Host calls Configure<DeploymentManagerOptions>
|
||||||
|
// against OptionsSection, then AddDeploymentManager().
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
@@ -23,7 +40,9 @@ public class ServiceCollectionExtensionsTests
|
|||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddDeploymentManager(configuration);
|
services.Configure<DeploymentManagerOptions>(
|
||||||
|
configuration.GetSection(ServiceCollectionExtensions.OptionsSection));
|
||||||
|
services.AddDeploymentManager();
|
||||||
|
|
||||||
using var provider = services.BuildServiceProvider();
|
using var provider = services.BuildServiceProvider();
|
||||||
var options = provider.GetRequiredService<IOptions<DeploymentManagerOptions>>().Value;
|
var options = provider.GetRequiredService<IOptions<DeploymentManagerOptions>>().Value;
|
||||||
@@ -33,17 +52,8 @@ public class ServiceCollectionExtensionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddDeploymentManager_WithConfiguration_MissingSection_UsesDefaults()
|
public void OptionsSection_MatchesTheConventionalComponentSectionPath()
|
||||||
{
|
{
|
||||||
var configuration = new ConfigurationBuilder().Build();
|
Assert.Equal("ScadaLink:DeploymentManager", ServiceCollectionExtensions.OptionsSection);
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
services.AddDeploymentManager(configuration);
|
|
||||||
|
|
||||||
using var provider = services.BuildServiceProvider();
|
|
||||||
var options = provider.GetRequiredService<IOptions<DeploymentManagerOptions>>().Value;
|
|
||||||
|
|
||||||
// No section present -> the option-class defaults are retained.
|
|
||||||
Assert.Equal(TimeSpan.FromSeconds(5), options.OperationLockTimeout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ScadaLink.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection
|
||||||
|
/// strings — they are externalised to environment variables. Tests that exercise the
|
||||||
|
/// full <c>Program</c> startup pipeline against the real SQL provider must therefore
|
||||||
|
/// supply the local dev connection strings the way a deployment would: via
|
||||||
|
/// environment variables (<c>Program</c>'s configuration builder calls
|
||||||
|
/// <c>AddEnvironmentVariables()</c>).
|
||||||
|
///
|
||||||
|
/// Dispose restores the previous values so tests stay isolated.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CentralDbTestEnvironment : IDisposable
|
||||||
|
{
|
||||||
|
// Local dev/test database — same credentials the infra docker-compose stack uses.
|
||||||
|
// This is a test fixture value, not a committed production secret.
|
||||||
|
private const string ConfigurationDb =
|
||||||
|
"Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true";
|
||||||
|
private const string MachineDataDb =
|
||||||
|
"Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true";
|
||||||
|
|
||||||
|
private const string ConfigKey = "ScadaLink__Database__ConfigurationDb";
|
||||||
|
private const string MachineKey = "ScadaLink__Database__MachineDataDb";
|
||||||
|
|
||||||
|
private readonly string? _previousConfig;
|
||||||
|
private readonly string? _previousMachine;
|
||||||
|
|
||||||
|
public CentralDbTestEnvironment()
|
||||||
|
{
|
||||||
|
_previousConfig = Environment.GetEnvironmentVariable(ConfigKey);
|
||||||
|
_previousMachine = Environment.GetEnvironmentVariable(MachineKey);
|
||||||
|
Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb);
|
||||||
|
Environment.SetEnvironmentVariable(MachineKey, MachineDataDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(ConfigKey, _previousConfig);
|
||||||
|
Environment.SetEnvironmentVariable(MachineKey, _previousMachine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,6 +298,9 @@ public class SiteCompositionRootTests : IDisposable
|
|||||||
["ScadaLink:Node:RemotingPort"] = "0",
|
["ScadaLink:Node:RemotingPort"] = "0",
|
||||||
["ScadaLink:Node:GrpcPort"] = "0",
|
["ScadaLink:Node:GrpcPort"] = "0",
|
||||||
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
||||||
|
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
|
||||||
|
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||||
});
|
});
|
||||||
|
|
||||||
// gRPC server registration (mirrors Program.cs site section)
|
// gRPC server registration (mirrors Program.cs site section)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ScadaLink.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-003 regression: secrets must not be committed in plaintext in the
|
||||||
|
/// shipped <c>appsettings.Central.json</c>. Connection-string passwords, the LDAP
|
||||||
|
/// service-account password and the JWT signing key must be supplied via
|
||||||
|
/// environment variables (or another secret store) at deployment time — the
|
||||||
|
/// committed file may only carry non-sensitive structural defaults or
|
||||||
|
/// placeholder values.
|
||||||
|
/// </summary>
|
||||||
|
public class ConfigSecretsTests
|
||||||
|
{
|
||||||
|
private static string FindHostProjectDirectory()
|
||||||
|
{
|
||||||
|
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||||
|
var dir = new DirectoryInfo(assemblyDir);
|
||||||
|
while (dir != null)
|
||||||
|
{
|
||||||
|
var hostPath = Path.Combine(dir.FullName, "src", "ScadaLink.Host");
|
||||||
|
if (Directory.Exists(hostPath))
|
||||||
|
return hostPath;
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
throw new DirectoryNotFoundException("Could not locate src/ScadaLink.Host");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement ScadaLinkSection()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(FindHostProjectDirectory(), "appsettings.Central.json");
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
return doc.RootElement.GetProperty("ScadaLink").Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CentralConfig_ConnectionStrings_ContainNoPlaintextPassword()
|
||||||
|
{
|
||||||
|
var db = ScadaLinkSection().GetProperty("Database");
|
||||||
|
foreach (var prop in db.EnumerateObject())
|
||||||
|
{
|
||||||
|
var value = prop.Value.GetString() ?? string.Empty;
|
||||||
|
// A committed connection string must not carry a literal Password= value.
|
||||||
|
// Either the password is delivered via an environment variable or the
|
||||||
|
// whole connection string is. A placeholder reference is acceptable.
|
||||||
|
var idx = value.IndexOf("Password=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
var after = value[(idx + "Password=".Length)..];
|
||||||
|
var literal = after.Split(';')[0];
|
||||||
|
Assert.True(
|
||||||
|
literal.Length == 0 || literal.Contains('{') || literal.Contains('$'),
|
||||||
|
$"appsettings.Central.json '{prop.Name}' contains a plaintext Password value '{literal}'. " +
|
||||||
|
"Move the secret to an environment variable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CentralConfig_LdapServiceAccountPassword_IsNotCommitted()
|
||||||
|
{
|
||||||
|
var security = ScadaLinkSection().GetProperty("Security");
|
||||||
|
if (security.TryGetProperty("LdapServiceAccountPassword", out var pw))
|
||||||
|
{
|
||||||
|
var value = pw.GetString() ?? string.Empty;
|
||||||
|
Assert.True(
|
||||||
|
value.Length == 0 || value.Contains('{') || value.Contains('$'),
|
||||||
|
$"appsettings.Central.json carries a plaintext LdapServiceAccountPassword '{value}'. " +
|
||||||
|
"Move it to an environment variable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CentralConfig_JwtSigningKey_IsNotCommitted()
|
||||||
|
{
|
||||||
|
var security = ScadaLinkSection().GetProperty("Security");
|
||||||
|
if (security.TryGetProperty("JwtSigningKey", out var key))
|
||||||
|
{
|
||||||
|
var value = key.GetString() ?? string.Empty;
|
||||||
|
Assert.True(
|
||||||
|
value.Length == 0 || value.Contains('{') || value.Contains('$'),
|
||||||
|
$"appsettings.Central.json carries a committed JwtSigningKey '{value}'. " +
|
||||||
|
"A committed signing key lets anyone with repo access forge session tokens. " +
|
||||||
|
"Move it to an environment variable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ public class HealthCheckTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly List<IDisposable> _disposables = new();
|
private readonly List<IDisposable> _disposables = new();
|
||||||
|
|
||||||
|
public HealthCheckTests()
|
||||||
|
{
|
||||||
|
// Host-003: connection strings are externalised; supply them via env vars.
|
||||||
|
_disposables.Add(new CentralDbTestEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
foreach (var d in _disposables)
|
foreach (var d in _disposables)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public class HostStartupTests : IDisposable
|
|||||||
// Set the environment to Central so appsettings.Central.json is loaded,
|
// Set the environment to Central so appsettings.Central.json is loaded,
|
||||||
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
||||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
|
// Host-003: connection strings are externalised; supply them via env vars.
|
||||||
|
using var dbEnv = new CentralDbTestEnvironment();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
|||||||
@@ -254,6 +254,62 @@ public class StartupValidatorTests
|
|||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_SeedNodeOnGrpcPort_FailsValidation()
|
||||||
|
{
|
||||||
|
// Host-004 regression: a site seed node must reference an Akka remoting
|
||||||
|
// endpoint, never the Kestrel HTTP/2 gRPC port. A seed node whose port
|
||||||
|
// equals this node's GrpcPort would make a joining node attempt an
|
||||||
|
// Akka.Remote TCP association against the gRPC listener and fail.
|
||||||
|
var values = ValidSiteConfig();
|
||||||
|
values["ScadaLink:Node:GrpcPort"] = "8083";
|
||||||
|
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node1:8083";
|
||||||
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||||
|
Assert.Contains("must not target the gRPC port", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_SeedNodeOnDefaultGrpcPort_FailsValidation()
|
||||||
|
{
|
||||||
|
// GrpcPort is absent here, so the NodeOptions default of 8083 applies.
|
||||||
|
// A seed node on 8083 must still be rejected.
|
||||||
|
var values = ValidSiteConfig();
|
||||||
|
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node2:8083";
|
||||||
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||||
|
Assert.Contains("must not target the gRPC port", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_SeedNodesOnRemotingPort_PassesValidation()
|
||||||
|
{
|
||||||
|
// Two distinct site nodes, both seed entries on the remoting port (8082).
|
||||||
|
var values = ValidSiteConfig();
|
||||||
|
values["ScadaLink:Node:GrpcPort"] = "8083";
|
||||||
|
values["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@site-a-node1:8082";
|
||||||
|
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node2:8082";
|
||||||
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Central_SeedNodeOnPort8083_PassesValidation()
|
||||||
|
{
|
||||||
|
// The gRPC-port rule applies to Site nodes only. A Central node has no
|
||||||
|
// GrpcPort, so a seed node on 8083 must not be rejected.
|
||||||
|
var values = ValidCentralConfig();
|
||||||
|
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@central-node2:8083";
|
||||||
|
var config = BuildConfig(values);
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MultipleErrors_AllReported()
|
public void MultipleErrors_AllReported()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ScadaLink.InboundAPI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InboundAPI-006 / InboundAPI-008: the POST /api/{methodName} endpoint must be
|
||||||
|
/// gated to the active central node and must cap the request body size. These
|
||||||
|
/// behaviours are enforced by <see cref="InboundApiEndpointFilter"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class EndpointGatingTests
|
||||||
|
{
|
||||||
|
private static InboundApiEndpointFilter CreateFilter(InboundApiOptions? options = null) =>
|
||||||
|
new(NullLogger<InboundApiEndpointFilter>.Instance,
|
||||||
|
Options.Create(options ?? new InboundApiOptions()));
|
||||||
|
|
||||||
|
private static (DefaultHttpContext ctx, DefaultEndpointFilterInvocationContext invocation)
|
||||||
|
BuildInvocation(bool? activeNode, long? contentLength)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
if (activeNode.HasValue)
|
||||||
|
services.AddSingleton<IActiveNodeGate>(new StubActiveNodeGate(activeNode.Value));
|
||||||
|
|
||||||
|
var ctx = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
RequestServices = services.BuildServiceProvider()
|
||||||
|
};
|
||||||
|
ctx.Request.Method = "POST";
|
||||||
|
if (contentLength.HasValue)
|
||||||
|
ctx.Request.ContentLength = contentLength.Value;
|
||||||
|
|
||||||
|
return (ctx, new DefaultEndpointFilterInvocationContext(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EndpointFilterDelegate NextSentinel(out Func<bool> wasCalled)
|
||||||
|
{
|
||||||
|
var called = false;
|
||||||
|
wasCalled = () => called;
|
||||||
|
return _ =>
|
||||||
|
{
|
||||||
|
called = true;
|
||||||
|
return ValueTask.FromResult<object?>(Results.Ok("handler-ran"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-008: standby node must not serve inbound API calls ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler()
|
||||||
|
{
|
||||||
|
var (_, invocation) = BuildInvocation(activeNode: false, contentLength: 2);
|
||||||
|
var next = NextSentinel(out var handlerRan);
|
||||||
|
|
||||||
|
var result = await CreateFilter().InvokeAsync(invocation, next);
|
||||||
|
|
||||||
|
Assert.False(handlerRan());
|
||||||
|
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||||
|
Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ActiveNode_PassesGate_RunsHandler()
|
||||||
|
{
|
||||||
|
var (_, invocation) = BuildInvocation(activeNode: true, contentLength: 2);
|
||||||
|
var next = NextSentinel(out var handlerRan);
|
||||||
|
|
||||||
|
await CreateFilter().InvokeAsync(invocation, next);
|
||||||
|
|
||||||
|
Assert.True(handlerRan());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoGateRegistered_PassesGate_RunsHandler()
|
||||||
|
{
|
||||||
|
// When no IActiveNodeGate is registered (non-clustered host), gating is
|
||||||
|
// opt-in and defaults to "allow" so the endpoint is still served.
|
||||||
|
var (_, invocation) = BuildInvocation(activeNode: null, contentLength: 2);
|
||||||
|
var next = NextSentinel(out var handlerRan);
|
||||||
|
|
||||||
|
await CreateFilter().InvokeAsync(invocation, next);
|
||||||
|
|
||||||
|
Assert.True(handlerRan());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-006: request body size must be capped ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler()
|
||||||
|
{
|
||||||
|
var options = new InboundApiOptions();
|
||||||
|
var (_, invocation) = BuildInvocation(
|
||||||
|
activeNode: true, contentLength: options.MaxRequestBodyBytes + 1);
|
||||||
|
var next = NextSentinel(out var handlerRan);
|
||||||
|
|
||||||
|
var result = await CreateFilter(options).InvokeAsync(invocation, next);
|
||||||
|
|
||||||
|
Assert.False(handlerRan());
|
||||||
|
var status = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||||
|
Assert.Equal(StatusCodes.Status413PayloadTooLarge, status.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BodyAtLimit_RunsHandler()
|
||||||
|
{
|
||||||
|
var options = new InboundApiOptions();
|
||||||
|
var (_, invocation) = BuildInvocation(
|
||||||
|
activeNode: true, contentLength: options.MaxRequestBodyBytes);
|
||||||
|
var next = NextSentinel(out var handlerRan);
|
||||||
|
|
||||||
|
await CreateFilter(options).InvokeAsync(invocation, next);
|
||||||
|
|
||||||
|
Assert.True(handlerRan());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FilterCapsMaxRequestBodySizeFeature()
|
||||||
|
{
|
||||||
|
// For chunked/unknown-length requests there is no Content-Length, so the
|
||||||
|
// filter must also cap the per-request body size feature so Kestrel rejects
|
||||||
|
// an oversized stream while it is being read.
|
||||||
|
var options = new InboundApiOptions();
|
||||||
|
var (ctx, invocation) = BuildInvocation(activeNode: true, contentLength: null);
|
||||||
|
ctx.Features.Set<IHttpMaxRequestBodySizeFeature>(new StubMaxBodySizeFeature());
|
||||||
|
var next = NextSentinel(out _);
|
||||||
|
|
||||||
|
await CreateFilter(options).InvokeAsync(invocation, next);
|
||||||
|
|
||||||
|
var feature = ctx.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||||
|
Assert.Equal(options.MaxRequestBodyBytes, feature!.MaxRequestBodySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubActiveNodeGate : IActiveNodeGate
|
||||||
|
{
|
||||||
|
private readonly bool _isActive;
|
||||||
|
public StubActiveNodeGate(bool isActive) => _isActive = isActive;
|
||||||
|
public bool IsActiveNode => _isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubMaxBodySizeFeature : IHttpMaxRequestBodySizeFeature
|
||||||
|
{
|
||||||
|
public bool IsReadOnly => false;
|
||||||
|
public long? MaxRequestBodySize { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,4 +239,88 @@ public class InboundScriptExecutorTests
|
|||||||
|
|
||||||
Assert.True(_executor.CompileAndRegister(method));
|
Assert.True(_executor.CompileAndRegister(method));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-002: lazy compile-and-fetch must be atomic, never KeyNotFoundException ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LazyCompile_RacingRemoveHandler_NeverThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
// The lazy-compile path must compile-and-fetch atomically: a concurrent
|
||||||
|
// RemoveHandler must not be able to turn a first-call into an "Internal
|
||||||
|
// script error" (the old check-then-act re-read could throw KeyNotFoundException).
|
||||||
|
var method = new ApiMethod("atomic", "return 5;") { Id = 1, TimeoutSeconds = 10 };
|
||||||
|
|
||||||
|
var removers = Enumerable.Range(0, 16).Select(_ => Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (var n = 0; n < 200; n++)
|
||||||
|
_executor.RemoveHandler("atomic");
|
||||||
|
}));
|
||||||
|
|
||||||
|
var callers = Enumerable.Range(0, 16).Select(_ => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
for (var n = 0; n < 50; n++)
|
||||||
|
{
|
||||||
|
var r = await _executor.ExecuteAsync(
|
||||||
|
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||||
|
// Result must always be a clean success or a clean compilation
|
||||||
|
// failure — never the catch-all "Internal script error".
|
||||||
|
Assert.NotEqual("Internal script error", r.ErrorMessage);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Task.WhenAll(removers.Concat(callers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- InboundAPI-004: a client disconnect must NOT be reported as a script timeout ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientDisconnect_IsNotReportedAsTimeout()
|
||||||
|
{
|
||||||
|
// When the caller's request token is cancelled (client aborted the request),
|
||||||
|
// ExecuteAsync must report a client-cancelled failure, not "Script execution
|
||||||
|
// timed out" — that log line is reserved for genuine timeouts.
|
||||||
|
var method = new ApiMethod("aborted", "return 1;") { Id = 1, TimeoutSeconds = 30 };
|
||||||
|
_executor.RegisterHandler("aborted", async ctx =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
||||||
|
return "never";
|
||||||
|
});
|
||||||
|
|
||||||
|
using var clientAborted = new CancellationTokenSource();
|
||||||
|
clientAborted.CancelAfter(TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
var result = await _executor.ExecuteAsync(
|
||||||
|
method,
|
||||||
|
new Dictionary<string, object?>(),
|
||||||
|
_route,
|
||||||
|
// Generous method timeout so the timeout CTS is NOT the cause.
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
clientAborted.Token);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.DoesNotContain("timed out", result.ErrorMessage ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenuineTimeout_StillReportedAsTimeout()
|
||||||
|
{
|
||||||
|
// A method that exceeds its timeout with no client abort must still be
|
||||||
|
// reported as "timed out" (regression guard for the InboundAPI-004 fix).
|
||||||
|
var method = new ApiMethod("genuine", "return 1;") { Id = 1, TimeoutSeconds = 1 };
|
||||||
|
_executor.RegisterHandler("genuine", async ctx =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
|
||||||
|
return "never";
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _executor.ExecuteAsync(
|
||||||
|
method,
|
||||||
|
new Dictionary<string, object?>(),
|
||||||
|
_route,
|
||||||
|
TimeSpan.FromMilliseconds(100),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("timed out", result.ErrorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
|||||||
@@ -668,4 +668,66 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Serialization (finding ManagementService-007)
|
||||||
|
//
|
||||||
|
// Command results are serialized with System.Text.Json configured with
|
||||||
|
// ReferenceHandler.IgnoreCycles, so an entity graph with a bidirectional
|
||||||
|
// navigation property does not throw. Property names are camelCase, which
|
||||||
|
// the CLI's case-insensitive deserializer accepts.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private sealed class CyclicNode
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public CyclicNode? Parent { get; set; }
|
||||||
|
public List<CyclicNode> Children { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeResult_WithCyclicGraph_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var parent = new CyclicNode { Name = "Parent" };
|
||||||
|
var child = new CyclicNode { Name = "Child", Parent = parent };
|
||||||
|
parent.Children.Add(child); // parent <-> child cycle
|
||||||
|
|
||||||
|
var json = ManagementActor.SerializeResult(parent);
|
||||||
|
|
||||||
|
Assert.Contains("Parent", json);
|
||||||
|
Assert.Contains("Child", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializeResult_UsesCamelCasePropertyNames()
|
||||||
|
{
|
||||||
|
var json = ManagementActor.SerializeResult(new CyclicNode { Name = "X" });
|
||||||
|
|
||||||
|
Assert.Contains("\"name\"", json);
|
||||||
|
Assert.DoesNotContain("\"Name\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PipeTo fault mapping (finding ManagementService-004)
|
||||||
|
//
|
||||||
|
// Command processing is piped back via PipeTo; a fault raised inside
|
||||||
|
// DispatchCommand must be mapped to ManagementError by the failure
|
||||||
|
// continuation rather than escaping or being silently dropped.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownCommandType_FaultMappedToManagementError()
|
||||||
|
{
|
||||||
|
// ManagementEnvelope.Command is typed object; an unrecognised payload
|
||||||
|
// makes DispatchCommand throw NotSupportedException. The PipeTo failure
|
||||||
|
// continuation must map it to ManagementError.
|
||||||
|
var actor = CreateActor();
|
||||||
|
var envelope = Envelope("not-a-command");
|
||||||
|
|
||||||
|
actor.Tell(envelope);
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||||
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
|
using ScadaLink.ManagementService;
|
||||||
|
|
||||||
|
namespace ScadaLink.ManagementService.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="ManagementEndpoints"/> request-body parsing
|
||||||
|
/// (findings ManagementService-006 / -013).
|
||||||
|
/// </summary>
|
||||||
|
public class ManagementEndpointsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseCommand_WithExplicitPayload_DeserializesIntoCommandType()
|
||||||
|
{
|
||||||
|
var json = """{ "command": "CreateSite", "payload": { "name": "Site1", "siteIdentifier": "SITE1", "description": "Desc" } }""";
|
||||||
|
|
||||||
|
var result = ManagementEndpoints.ParseCommand(json);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
var command = Assert.IsType<CreateSiteCommand>(result.Command);
|
||||||
|
Assert.Equal("Site1", command.Name);
|
||||||
|
Assert.Equal("SITE1", command.SiteIdentifier);
|
||||||
|
Assert.Equal("Desc", command.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCommand_WithMissingPayload_DeserializesParameterlessCommand()
|
||||||
|
{
|
||||||
|
// No "payload" field at all -- the fallback must not allocate a throwaway
|
||||||
|
// JsonDocument and must still produce a valid parameterless command.
|
||||||
|
var json = """{ "command": "ListTemplates" }""";
|
||||||
|
|
||||||
|
var result = ManagementEndpoints.ParseCommand(json);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.IsType<ListTemplatesCommand>(result.Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCommand_WithInvalidJson_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var result = ManagementEndpoints.ParseCommand("{ not json");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("BAD_REQUEST", result.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCommand_WithMissingCommandField_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var result = ManagementEndpoints.ParseCommand("""{ "payload": {} }""");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("BAD_REQUEST", result.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCommand_WithUnknownCommand_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var result = ManagementEndpoints.ParseCommand("""{ "command": "NoSuchCommand" }""");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("BAD_REQUEST", result.ErrorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ScadaLink.NotificationService.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-009: Tests for scrubbing SMTP credential secrets out of log/result text.
|
||||||
|
/// </summary>
|
||||||
|
public class CredentialRedactorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Scrub_BasicAuthPassword_IsMasked()
|
||||||
|
{
|
||||||
|
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pw!'";
|
||||||
|
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pw!");
|
||||||
|
|
||||||
|
Assert.DoesNotContain("Hunter2pw!", result);
|
||||||
|
Assert.DoesNotContain("svc:Hunter2pw!", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scrub_OAuth2ClientSecret_IsMasked()
|
||||||
|
{
|
||||||
|
var text = "Token request failed: client_secret=Sup3rSecretValue rejected by tenant";
|
||||||
|
var result = CredentialRedactor.Scrub(text, "tenant-guid:client-guid:Sup3rSecretValue");
|
||||||
|
|
||||||
|
Assert.DoesNotContain("Sup3rSecretValue", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scrub_NullCredentials_ReturnsTextUnchanged()
|
||||||
|
{
|
||||||
|
Assert.Equal("plain text", CredentialRedactor.Scrub("plain text", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scrub_NullText_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,8 @@ public class NotificationDeliveryServiceTests
|
|||||||
|
|
||||||
await service.SendAsync("ops-team", "Alert", "Body");
|
await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
await _smtpClient.Received().ConnectAsync("smtp.example.com", 587, true, Arg.Any<CancellationToken>());
|
await _smtpClient.Received().ConnectAsync(
|
||||||
|
"smtp.example.com", 587, SmtpTlsMode.StartTls, Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||||
await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any<CancellationToken>());
|
await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any<CancellationToken>());
|
||||||
await _smtpClient.Received().SendAsync(
|
await _smtpClient.Received().SendAsync(
|
||||||
"noreply@example.com",
|
"noreply@example.com",
|
||||||
@@ -363,7 +364,7 @@ public class NotificationDeliveryServiceTests
|
|||||||
private sealed class TrackingSmtpClient : ISmtpClientWrapper, IDisposable
|
private sealed class TrackingSmtpClient : ISmtpClientWrapper, IDisposable
|
||||||
{
|
{
|
||||||
public bool Disposed { get; private set; }
|
public bool Disposed { get; private set; }
|
||||||
public Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default)
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
|
||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
||||||
=> Task.CompletedTask;
|
=> Task.CompletedTask;
|
||||||
@@ -405,4 +406,240 @@ public class NotificationDeliveryServiceTests
|
|||||||
return new StoreAndForwardService(
|
return new StoreAndForwardService(
|
||||||
storage, new StoreAndForwardOptions(), NullLogger<StoreAndForwardService>.Instance);
|
storage, new StoreAndForwardOptions(), NullLogger<StoreAndForwardService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NotificationService-005: explicit TLS mode passed through to the wrapper ──
|
||||||
|
|
||||||
|
/// <summary>An SMTP wrapper that records the TLS mode and timeout it was connected with.</summary>
|
||||||
|
private sealed class RecordingTlsClient : ISmtpClientWrapper
|
||||||
|
{
|
||||||
|
public SmtpTlsMode? TlsMode { get; private set; }
|
||||||
|
public int ConnectionTimeoutSeconds { get; private set; }
|
||||||
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
TlsMode = tlsMode;
|
||||||
|
ConnectionTimeoutSeconds = connectionTimeoutSeconds;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupHappyPathWithSmtp(SmtpConfiguration smtpConfig)
|
||||||
|
{
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1 };
|
||||||
|
var recipients = new List<NotificationRecipient>
|
||||||
|
{
|
||||||
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
|
||||||
|
};
|
||||||
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
||||||
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_TlsModeNone_DoesNotNegotiateTls()
|
||||||
|
{
|
||||||
|
// NS-005: TlsMode "none" must connect with SmtpTlsMode.None, not the old
|
||||||
|
// SecureSocketOptions.Auto (which let MailKit opportunistically negotiate TLS).
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 25, Credentials = "user:pass", TlsMode = "none"
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
var recording = new RecordingTlsClient();
|
||||||
|
var service = new NotificationDeliveryService(
|
||||||
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(SmtpTlsMode.None, recording.TlsMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_TlsModeSsl_UsesImplicitSsl()
|
||||||
|
{
|
||||||
|
// NS-005: TlsMode "ssl" (port 465 implicit TLS) must be honoured, not
|
||||||
|
// collapsed into the same path as "none".
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 465, Credentials = "user:pass", TlsMode = "ssl"
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
var recording = new RecordingTlsClient();
|
||||||
|
var service = new NotificationDeliveryService(
|
||||||
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(SmtpTlsMode.Ssl, recording.TlsMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_UnknownTlsMode_ReturnsErrorNotSilentFallback()
|
||||||
|
{
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "bogus"
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
var service = new NotificationDeliveryService(
|
||||||
|
_repository, () => new RecordingTlsClient(), NullLogger<NotificationDeliveryService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("TLS mode", result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NotificationService-007: connection timeout passed through to the wrapper ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_PassesConfiguredConnectionTimeoutToClient()
|
||||||
|
{
|
||||||
|
// NS-007: SmtpConfiguration.ConnectionTimeoutSeconds must reach the wrapper
|
||||||
|
// so SmtpClient.Timeout is set; it was previously dead configuration.
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
||||||
|
ConnectionTimeoutSeconds = 17
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
var recording = new RecordingTlsClient();
|
||||||
|
var service = new NotificationDeliveryService(
|
||||||
|
_repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);
|
||||||
|
|
||||||
|
await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.Equal(17, recording.ConnectionTimeoutSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_MaxConcurrentConnections_LimitsConcurrentDeliveries()
|
||||||
|
{
|
||||||
|
// NS-007: MaxConcurrentConnections must throttle concurrent SMTP deliveries.
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
|
||||||
|
MaxConcurrentConnections = 2
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
|
||||||
|
var inFlight = 0;
|
||||||
|
var maxObserved = 0;
|
||||||
|
var gate = new SemaphoreSlim(0);
|
||||||
|
var sync = new object();
|
||||||
|
|
||||||
|
var service = new NotificationDeliveryService(
|
||||||
|
_repository,
|
||||||
|
() => new BlockingSmtpClient(
|
||||||
|
onSend: async () =>
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
inFlight++;
|
||||||
|
if (inFlight > maxObserved) maxObserved = inFlight;
|
||||||
|
}
|
||||||
|
await gate.WaitAsync();
|
||||||
|
lock (sync) { inFlight--; }
|
||||||
|
}),
|
||||||
|
NullLogger<NotificationDeliveryService>.Instance);
|
||||||
|
|
||||||
|
var sends = Enumerable.Range(0, 6)
|
||||||
|
.Select(_ => service.SendAsync("ops-team", "Alert", "Body"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Give the throttled sends time to reach the SMTP send call.
|
||||||
|
await Task.Delay(200);
|
||||||
|
gate.Release(6);
|
||||||
|
await Task.WhenAll(sends);
|
||||||
|
|
||||||
|
Assert.True(maxObserved <= 2, $"Expected at most 2 concurrent deliveries, observed {maxObserved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingSmtpClient : ISmtpClientWrapper, IDisposable
|
||||||
|
{
|
||||||
|
private readonly Func<Task> _onSend;
|
||||||
|
public BlockingSmtpClient(Func<Task> onSend) => _onSend = onSend;
|
||||||
|
public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
|
||||||
|
=> _onSend();
|
||||||
|
public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NotificationService-008: recipient address validation ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// NS-008: a malformed recipient address previously caused MailboxAddress.Parse
|
||||||
|
// to throw ParseException, which escaped SendAsync unhandled. It must now
|
||||||
|
// produce a clean NotificationResult failure.
|
||||||
|
var list = new NotificationList("ops-team") { Id = 1 };
|
||||||
|
var recipients = new List<NotificationRecipient>
|
||||||
|
{
|
||||||
|
new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
|
||||||
|
new("Bad", "not a valid address @@") { Id = 2, NotificationListId = 1 }
|
||||||
|
};
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
||||||
|
};
|
||||||
|
_repository.GetListByNameAsync("ops-team").Returns(list);
|
||||||
|
_repository.GetRecipientsByListIdAsync(1).Returns(recipients);
|
||||||
|
_repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { cfg });
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("not a valid address @@", result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NotificationService-009: credential secrets scrubbed from logs/results ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_PermanentError_RedactsCredentialFromResultMessage()
|
||||||
|
{
|
||||||
|
// NS-009: a permanent-failure message echoing a credential fragment must be
|
||||||
|
// scrubbed before it reaches the caller-facing NotificationResult.
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls"
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
_smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Throws(new SmtpPermanentException("550 rejected — password Hunter2Secret is invalid"));
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var cfg = new SmtpConfiguration("smtp.example.com", "basic", "@@bad-from@@")
|
||||||
|
{
|
||||||
|
Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
|
||||||
|
};
|
||||||
|
SetupHappyPathWithSmtp(cfg);
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
var result = await service.SendAsync("ops-team", "Alert", "Body");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,70 @@ public class OAuth2TokenServiceTests
|
|||||||
() => service.GetTokenAsync("tenant:client:secret"));
|
() => service.GetTokenAsync("tenant:client:secret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NotificationService-006: token cache must be keyed to credential identity ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTokenAsync_DifferentCredentials_ReturnPerCredentialTokens()
|
||||||
|
{
|
||||||
|
// NS-006: the singleton cached a single token ignoring the credentials
|
||||||
|
// argument, so a second SMTP config with a different tenant/client got the
|
||||||
|
// first config's token. Each distinct credential must get its own token.
|
||||||
|
var handler = new PerTenantHttpMessageHandler();
|
||||||
|
var client = new HttpClient(handler);
|
||||||
|
var factory = CreateMockFactory(client);
|
||||||
|
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||||
|
|
||||||
|
var tokenA = await service.GetTokenAsync("tenantA:clientA:secretA");
|
||||||
|
var tokenB = await service.GetTokenAsync("tenantB:clientB:secretB");
|
||||||
|
|
||||||
|
Assert.Equal("token-for-tenantA", tokenA);
|
||||||
|
Assert.Equal("token-for-tenantB", tokenB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTokenAsync_SameCredentials_CachedPerCredential()
|
||||||
|
{
|
||||||
|
// NS-006: caching still works — repeated calls with the same credential
|
||||||
|
// identity make exactly one HTTP call.
|
||||||
|
var handler = new PerTenantHttpMessageHandler();
|
||||||
|
var client = new HttpClient(handler);
|
||||||
|
var factory = CreateMockFactory(client);
|
||||||
|
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||||
|
|
||||||
|
await service.GetTokenAsync("tenantA:clientA:secretA");
|
||||||
|
await service.GetTokenAsync("tenantA:clientA:secretA");
|
||||||
|
await service.GetTokenAsync("tenantB:clientB:secretB");
|
||||||
|
|
||||||
|
Assert.Equal(2, handler.CallCount); // one per distinct credential, not per call
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP handler that returns a distinct access token per tenant id, parsed from
|
||||||
|
/// the request URL (<c>https://login.microsoftonline.com/{tenantId}/...</c>).
|
||||||
|
/// </summary>
|
||||||
|
private class PerTenantHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
var segments = request.RequestUri!.AbsolutePath.Trim('/').Split('/');
|
||||||
|
var tenantId = segments[0];
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
access_token = $"token-for-{tenantId}",
|
||||||
|
expires_in = 3600,
|
||||||
|
token_type = "Bearer"
|
||||||
|
});
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(json)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Simple mock HTTP handler that returns a fixed response.
|
/// Simple mock HTTP handler that returns a fixed response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace ScadaLink.NotificationService.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NS-005: Tests for parsing the configured SMTP TLS mode into the three-state enum.
|
||||||
|
/// </summary>
|
||||||
|
public class SmtpTlsModeParserTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("none", SmtpTlsMode.None)]
|
||||||
|
[InlineData("None", SmtpTlsMode.None)]
|
||||||
|
[InlineData("NONE", SmtpTlsMode.None)]
|
||||||
|
[InlineData("starttls", SmtpTlsMode.StartTls)]
|
||||||
|
[InlineData("StartTLS", SmtpTlsMode.StartTls)]
|
||||||
|
[InlineData("ssl", SmtpTlsMode.Ssl)]
|
||||||
|
[InlineData("SSL", SmtpTlsMode.Ssl)]
|
||||||
|
[InlineData(" starttls ", SmtpTlsMode.StartTls)]
|
||||||
|
public void Parse_KnownModes_ReturnsExpected(string input, SmtpTlsMode expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SmtpTlsModeParser.Parse(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Parse_NullOrEmpty_DefaultsToStartTls(string? input)
|
||||||
|
{
|
||||||
|
Assert.Equal(SmtpTlsMode.StartTls, SmtpTlsModeParser.Parse(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("auto")]
|
||||||
|
[InlineData("tls")]
|
||||||
|
[InlineData("implicit")]
|
||||||
|
public void Parse_UnknownMode_Throws(string input)
|
||||||
|
{
|
||||||
|
// NS-005: an unknown mode must be rejected, not silently treated as Auto.
|
||||||
|
Assert.Throws<ArgumentException>(() => SmtpTlsModeParser.Parse(input));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -475,6 +475,203 @@ public class SecurityReviewRegressionTests
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Code Review Regression Tests — Security-004/005/006/007
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter
|
||||||
|
/// and fallback DN), Security-005 (DN injection in the no-service-account fallback),
|
||||||
|
/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout
|
||||||
|
/// claim reset on every token refresh).
|
||||||
|
/// </summary>
|
||||||
|
public class SecurityReviewRegressionTests2
|
||||||
|
{
|
||||||
|
private static SecurityOptions JwtOptions() => new()
|
||||||
|
{
|
||||||
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
||||||
|
JwtExpiryMinutes = 15,
|
||||||
|
IdleTimeoutMinutes = 30,
|
||||||
|
JwtRefreshThresholdMinutes = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
|
||||||
|
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
|
||||||
|
|
||||||
|
// --- Security-004: search filter and fallback DN must use the same attribute ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute()
|
||||||
|
{
|
||||||
|
// The default user-id attribute is "uid"; the fallback DN must use it,
|
||||||
|
// not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable.
|
||||||
|
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid");
|
||||||
|
Assert.Equal("uid=alice,dc=example,dc=com", dn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute()
|
||||||
|
{
|
||||||
|
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName");
|
||||||
|
Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid()
|
||||||
|
{
|
||||||
|
Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security-005: DN-component escaping must be applied to the username ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildFallbackUserDn_EscapesDnMetacharacters()
|
||||||
|
{
|
||||||
|
// A hostile username must not be able to alter the DN structure: the comma
|
||||||
|
// that would otherwise start a new RDN ("ou=admins") must be escaped so the
|
||||||
|
// whole string remains a single RDN value.
|
||||||
|
var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid");
|
||||||
|
Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn);
|
||||||
|
// The comma from the username is backslash-escaped, so it does not act as an
|
||||||
|
// RDN separator: the only unescaped comma is the one joining RDN and base DN.
|
||||||
|
Assert.Contains(@"victim\,ou=admins", dn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscapeLdapDn_EscapesAllRfc4514Specials()
|
||||||
|
{
|
||||||
|
var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\e<f>g;h");
|
||||||
|
Assert.Equal(@"a\,b\+c\""d\\e\<f\>g\;h", escaped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces()
|
||||||
|
{
|
||||||
|
Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security-006: JWT issuer/audience must be bound and validated ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateToken_SetsIssuerAndAudience()
|
||||||
|
{
|
||||||
|
var service = CreateJwtService();
|
||||||
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||||
|
|
||||||
|
var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
|
||||||
|
Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
|
||||||
|
Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateToken_RejectsTokenWithWrongIssuer()
|
||||||
|
{
|
||||||
|
// A token signed with the same key but a foreign issuer must be rejected.
|
||||||
|
var options = JwtOptions();
|
||||||
|
var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey));
|
||||||
|
var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(
|
||||||
|
key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256);
|
||||||
|
var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
|
||||||
|
issuer: "some-other-system",
|
||||||
|
audience: JwtTokenService.TokenAudience,
|
||||||
|
claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") },
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(10),
|
||||||
|
signingCredentials: creds);
|
||||||
|
var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign);
|
||||||
|
|
||||||
|
var service = CreateJwtService(options);
|
||||||
|
Assert.Null(service.ValidateToken(foreignToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateToken_AcceptsOwnIssuerAndAudience()
|
||||||
|
{
|
||||||
|
var service = CreateJwtService();
|
||||||
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||||
|
Assert.NotNull(service.ValidateToken(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security-007: refresh must preserve the original LastActivity timestamp ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefreshToken_PreservesOriginalLastActivityClaim()
|
||||||
|
{
|
||||||
|
var service = CreateJwtService();
|
||||||
|
|
||||||
|
// Mint a token whose LastActivity is 20 minutes in the past (still inside the
|
||||||
|
// 30-minute idle window). Refresh must NOT move it forward to "now".
|
||||||
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
||||||
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||||
|
}, "test"));
|
||||||
|
|
||||||
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||||
|
Assert.NotNull(refreshed);
|
||||||
|
|
||||||
|
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
||||||
|
Assert.NotNull(refreshedPrincipal);
|
||||||
|
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||||
|
Assert.NotNull(claim);
|
||||||
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
||||||
|
// The carried timestamp must equal the original, not "now".
|
||||||
|
Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
|
||||||
|
$"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
|
||||||
|
{
|
||||||
|
// A user idle for 25 of the 30-minute window: a refresh fired by some background
|
||||||
|
// request must not make IsIdleTimedOut flip back to false forever.
|
||||||
|
var service = CreateJwtService();
|
||||||
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
||||||
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||||
|
}, "test"));
|
||||||
|
|
||||||
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||||
|
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
||||||
|
|
||||||
|
// Still 25 min idle after refresh — not reset to 0.
|
||||||
|
Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
|
||||||
|
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||||
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
||||||
|
Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
|
||||||
|
"Refresh wrongly reset the idle clock to ~now");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordActivity_UpdatesLastActivityToNow()
|
||||||
|
{
|
||||||
|
// Genuine user activity (a real request) — distinct from a token refresh —
|
||||||
|
// updates LastActivity to the current time.
|
||||||
|
var service = CreateJwtService();
|
||||||
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
||||||
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
||||||
|
}, "test"));
|
||||||
|
|
||||||
|
var touched = service.RecordActivity(principal, new[] { "Admin" }, null);
|
||||||
|
Assert.NotNull(touched);
|
||||||
|
|
||||||
|
var touchedPrincipal = service.ValidateToken(touched!);
|
||||||
|
var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
||||||
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
|
||||||
|
Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
|
||||||
|
"RecordActivity should set LastActivity to ~now");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region WP-9: Authorization Policy Tests
|
#region WP-9: Authorization Policy Tests
|
||||||
|
|
||||||
public class AuthorizationPolicyTests
|
public class AuthorizationPolicyTests
|
||||||
|
|||||||
Reference in New Issue
Block a user