feat(delmia-notifier): optional ScadaBridge:ApiKey in appsettings.json as a fallback to SCADABRIDGE_API_KEY (env still wins); README/env inventory/design row updated
The 2026-06-26 design kept the key env-only. The file is now an explicit fallback: SCADABRIDGE_API_KEY (non-blank) -> ScadaBridge:ApiKey (non-blank, trimmed) -> not configured. Env-only stays the recommended production posture; the failure message names both sources. Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -22,7 +22,7 @@ stdout contract, same exit-code semantics — so Delmia's invocation and output
|
|||||||
| Auth | `X-API-Key: <key>` header |
|
| Auth | `X-API-Key: <key>` header |
|
||||||
| CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) |
|
| CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) |
|
||||||
| Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) |
|
| Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) |
|
||||||
| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file |
|
| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file **(relaxed 2026-09-02: optional `ScadaBridge:ApiKey` file fallback; env still wins)** |
|
||||||
| Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) |
|
| Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) |
|
||||||
| Failover | Comma-list of base URLs; advance **only on connect failure** |
|
| Failover | Comma-list of base URLs; advance **only on connect failure** |
|
||||||
| Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` |
|
| Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` |
|
||||||
|
|||||||
+1
-1
@@ -114,7 +114,7 @@ Resolution order per value: command-line flag → env var → `~/.scadabridge/co
|
|||||||
|
|
||||||
| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|
| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `SCADABRIDGE_API_KEY` | Runtime (client) | No — the notifier's `appsettings.json` holds only `BaseUrls`/`TimeoutSeconds`/`LogPath`; the key is **env-only by design** | Inbound API key sent as `X-API-Key`. Read only from the environment, never a file. Missing/empty → prints `API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt. | e.g. `sbk_...` | Yes |
|
| `SCADABRIDGE_API_KEY` | Runtime (client) | Yes — optional `ScadaBridge:ApiKey` fallback (2026-09-02); env wins | Inbound API key sent as `X-API-Key`. Environment first, file fallback (`ScadaBridge:ApiKey`, used only when the variable is unset/blank). Neither set → prints `API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt. | e.g. `sbk_...` | Yes |
|
||||||
|
|
||||||
> This is also why `System.Environment` is wholly forbidden in the script trust model — it would let a
|
> This is also why `System.Environment` is wholly forbidden in the script trust model — it would let a
|
||||||
> site/inbound script exfiltrate `SCADABRIDGE_API_KEY` and other host env secrets.
|
> site/inbound script exfiltrate `SCADABRIDGE_API_KEY` and other host env secrets.
|
||||||
|
|||||||
@@ -34,12 +34,19 @@ internal static class ConfigLoader
|
|||||||
return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolve the API key from <c>SCADABRIDGE_API_KEY</c>; null/whitespace → null. Env accessor is injected for testability.</summary>
|
/// <summary>Resolve the API key: <c>SCADABRIDGE_API_KEY</c> wins when non-blank, otherwise <c>ScadaBridge:ApiKey</c> from <paramref name="cfg"/> (trimmed); null when neither is set. Env accessor is injected for testability.</summary>
|
||||||
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
|
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
|
||||||
/// <returns>The resolved API key, or <c>null</c> if unset/whitespace.</returns>
|
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
|
||||||
public static string? ResolveApiKey(Func<string, string?> envGet)
|
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
|
||||||
|
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg)
|
||||||
{
|
{
|
||||||
var key = envGet(ApiKeyEnvVar);
|
var env = envGet(ApiKeyEnvVar);
|
||||||
return string.IsNullOrWhiteSpace(key) ? null : key;
|
if (!string.IsNullOrWhiteSpace(env))
|
||||||
|
{
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = cfg.ScadaBridge.ApiKey;
|
||||||
|
return string.IsNullOrWhiteSpace(file) ? null : file.Trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ internal sealed class ScadaBridgeSection
|
|||||||
|
|
||||||
/// <summary>Optional diagnostic log file (relative to the exe); null/omitted → stderr only.</summary>
|
/// <summary>Optional diagnostic log file (relative to the exe); null/omitted → stderr only.</summary>
|
||||||
public string? LogPath { get; set; }
|
public string? LogPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional inbound API key. <c>SCADABRIDGE_API_KEY</c> in the environment takes precedence; this is the file fallback (design 2026-06-26, relaxed 2026-09-02).</summary>
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ internal static class Program
|
|||||||
return Reporter.Report(false, "no BaseUrls configured", stdout);
|
return Reporter.Report(false, "no BaseUrls configured", stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable);
|
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable, cfg);
|
||||||
if (apiKey is null)
|
if (apiKey is null)
|
||||||
{
|
{
|
||||||
log.Write("API key not configured (SCADABRIDGE_API_KEY)");
|
log.Write("API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)");
|
||||||
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY)", stdout);
|
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)", stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ recipe / NC-program download to notify the plant that a recipe was placed at a p
|
|||||||
It is the modern, drop-in replacement for the legacy `WWNotifier` (see
|
It is the modern, drop-in replacement for the legacy `WWNotifier` (see
|
||||||
[`../../docs/former-api-specs/dnc/Delmia-Integration-API.md`](../../docs/former-api-specs/dnc/Delmia-Integration-API.md),
|
[`../../docs/former-api-specs/dnc/Delmia-Integration-API.md`](../../docs/former-api-specs/dnc/Delmia-Integration-API.md),
|
||||||
*Surface B*), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
|
*Surface B*), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
|
||||||
method `DelmiaRecipeDownload`. The command-line flags, the `YES`/`NO` stdout contract, and the
|
method `DelmiaRecipeDownload` (as-deployed spec: [`docs/api-specs/Delmia-Recipe-Download-API.md`](../../docs/api-specs/Delmia-Recipe-Download-API.md)). The command-line flags, the `YES`/`NO` stdout contract, and the
|
||||||
exit-code semantics are **unchanged**, so Delmia's call site and output parsing need no changes — only
|
exit-code semantics are **unchanged**, so Delmia's call site and output parsing need no changes — only
|
||||||
the executable on disk and its `appsettings.json` are swapped.
|
the executable on disk and its `appsettings.json` are swapped.
|
||||||
|
|
||||||
@@ -70,7 +70,8 @@ Placed next to the exe (copied to the output on build/publish):
|
|||||||
"ScadaBridge": {
|
"ScadaBridge": {
|
||||||
"BaseUrls": "http://host-a:8085,http://host-b:8085",
|
"BaseUrls": "http://host-a:8085,http://host-b:8085",
|
||||||
"TimeoutSeconds": 30,
|
"TimeoutSeconds": 30,
|
||||||
"LogPath": "logs/delmia-notifier.log"
|
"LogPath": "logs/delmia-notifier.log",
|
||||||
|
"ApiKey": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -83,14 +84,16 @@ Placed next to the exe (copied to the output on build/publish):
|
|||||||
- **`TimeoutSeconds`** — per-attempt HTTP timeout (default `30`).
|
- **`TimeoutSeconds`** — per-attempt HTTP timeout (default `30`).
|
||||||
- **`LogPath`** — optional diagnostic log file, relative to the exe. Omit to log to stderr only.
|
- **`LogPath`** — optional diagnostic log file, relative to the exe. Omit to log to stderr only.
|
||||||
|
|
||||||
## Secret — `SCADABRIDGE_API_KEY`
|
## Secret — `SCADABRIDGE_API_KEY` (or `ScadaBridge:ApiKey`)
|
||||||
|
|
||||||
The inbound API key is read **only** from the environment variable `SCADABRIDGE_API_KEY` and is sent
|
The inbound API key is sent as `X-API-Key`. It is resolved from **`SCADABRIDGE_API_KEY`** first
|
||||||
as the `X-API-Key` header. It is never read from or written to a file. If unset/empty → `NO` +
|
(recommended for production — the key never touches disk), falling back to **`ScadaBridge:ApiKey`** in
|
||||||
`API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt.
|
`appsettings.json` when the variable is unset or blank. If neither is set → `NO` +
|
||||||
|
`API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt.
|
||||||
|
|
||||||
Set it on the machine/account that DELMIA runs under (e.g. a system/user environment variable, or the
|
Set the variable on the machine/account that DELMIA runs under (e.g. a system/user environment
|
||||||
service account's environment).
|
variable, or the service account's environment). If you use the file instead, restrict its ACL to the
|
||||||
|
account DELMIA runs under — the key is stored in plain text there.
|
||||||
|
|
||||||
## Building & publishing (Native AOT, `win-x64`)
|
## Building & publishing (Native AOT, `win-x64`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaBridge": {
|
"ScadaBridge": {
|
||||||
"BaseUrls": "http://localhost:9000",
|
"BaseUrls": "http://localhost:9000",
|
||||||
"TimeoutSeconds": 30,
|
"TimeoutSeconds": 30,
|
||||||
"LogPath": "logs/delmia-notifier.log"
|
"LogPath": "logs/delmia-notifier.log",
|
||||||
|
"ApiKey": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,42 @@ public class ConfigLoaderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveApiKey_returns_null_when_unset_or_whitespace()
|
public void ResolveApiKey_returns_null_when_unset_or_whitespace()
|
||||||
{
|
{
|
||||||
Assert.Null(ConfigLoader.ResolveApiKey(_ => null));
|
Assert.Null(ConfigLoader.ResolveApiKey(_ => null, new NotifierConfig()));
|
||||||
Assert.Null(ConfigLoader.ResolveApiKey(_ => " "));
|
Assert.Null(ConfigLoader.ResolveApiKey(_ => " ", new NotifierConfig()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveApiKey_returns_value_when_present()
|
public void ResolveApiKey_returns_value_when_present()
|
||||||
{
|
{
|
||||||
Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null));
|
Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null, new NotifierConfig()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_reads_ApiKey_from_ScadaBridge_section()
|
||||||
|
{
|
||||||
|
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\",\"ApiKey\":\"sbk_file\"}}");
|
||||||
|
Assert.Equal("sbk_file", cfg.ScadaBridge.ApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveApiKey_prefers_env_over_config()
|
||||||
|
{
|
||||||
|
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\"sbk_file\"}}");
|
||||||
|
Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_env" : null, cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveApiKey_falls_back_to_config_when_env_unset_or_blank()
|
||||||
|
{
|
||||||
|
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" sbk_file \"}}");
|
||||||
|
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => null, cfg));
|
||||||
|
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveApiKey_returns_null_when_neither_source_set()
|
||||||
|
{
|
||||||
|
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" \"}}");
|
||||||
|
Assert.Null(ConfigLoader.ResolveApiKey(_ => null, cfg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user