From 9742a66a9eabc07e99779c38fe7bb43cd253d635 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:37:25 -0500 Subject: [PATCH] docs(security): credential-reference namespaces + migration note (#815) docs/security.md gains the canonical table of per-driver namespaces, the five enforcement seams, the out-of-scope cases (appsettings ${secret:}, Sql connectionStringRef) and the migration steps for existing configs. Galaxy, OpcUaClient, Heidenhain, Configuration and driver-specs docs, the Galaxy options XML docs, the drivers rule file and the docker-dev compose comment point at it. docker-dev's env:GALAXY_MXGW_API_KEY stays valid (allowed legacy prefix), so no rig change is needed. Co-Authored-By: Claude --- .claude/rules/drivers.md | 8 +++ docker-dev/docker-compose.yml | 3 +- docs/Configuration.md | 2 +- docs/drivers/Galaxy.md | 8 ++- docs/drivers/Heidenhain.md | 6 +- docs/drivers/OpcUaClient.md | 6 ++ docs/security.md | 61 +++++++++++++++++++ docs/v2/driver-specs.md | 2 +- .../GalaxyDriverOptions.cs | 28 ++++----- 9 files changed, 102 insertions(+), 22 deletions(-) diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 1c2dfd3e1..ba4bf4094 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -319,6 +319,14 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. +- **Credential references are namespaced per driver (#815):** ONE table (`Core.Abstractions.DriverCredentialPolicy`) + + ONE parser/resolver (`CredentialReferences`) for Galaxy (`secret:galaxy/`·`secret:otopcua/galaxy/`·`env:OTOPCUA_GALAXY_`· + `env:GALAXY_MXGW_`), OpcUaClient (`secret:opcuaclient/`·`env:OTOPCUA_OPCUACLIENT_`) and Heidenhain. Enforced at save + (`DriverConfigSaveGate`), deploy (`CredentialReferenceOutOfScope`), Test Connect (`AdminOperationsActor`, before the + probe), browse (`BrowserSessionService`, before the browser) and at resolve time before any store/env read; `file:` is + refused everywhere. A NEW driver that resolves a `secret:`/`env:` field and sends it to an authored endpoint MUST add a + row there and resolve through `CredentialReferences` — never `ISecretResolver.GetAsync` on a raw authored name. + `docs/security.md` §"Credential references are namespaced per driver". - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 42baee8d3..abfc17977 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -679,7 +679,8 @@ services: Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" # Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's - # Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY". + # Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY" (#815: GALAXY_MXGW_* is an allowed + # Galaxy env prefix alongside OTOPCUA_GALAXY_*; any other variable is refused). GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" # Pair-local config cache + REPLICATION ON — the site-a pair is the enablement demo. # site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer. diff --git a/docs/Configuration.md b/docs/Configuration.md index b708fd53a..a9f229797 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -385,7 +385,7 @@ The Galaxy/MxAccess connection settings are **not an `appsettings` section.** Th | Record | Key fields (default) | Meaning | |---|---|---| -| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `env:NAME` / `file:PATH` / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `env:`/`file:` in production. Never store a cleartext key. | +| `GalaxyGatewayOptions` (`Gateway`) | `Endpoint`; `ApiKeySecretRef`; `UseTls` (`true`); `CaCertificatePath` (`null`); `ConnectTimeoutSeconds` (`10`); `DefaultCallTimeoutSeconds` (`30`); `StreamTimeoutSeconds` (`0` = unlimited) | mxaccessgw gateway connection. `ApiKeySecretRef` supports `secret:galaxy/` (or `secret:otopcua/galaxy/`) / `env:OTOPCUA_GALAXY_` (or legacy `env:GALAXY_MXGW_`) / `dev:KEY` / literal forms (resolved at `InitializeAsync`); prefer `secret:` in production. Any other `secret:`/`env:` reference and every `file:` reference is refused at save, deploy (`CredentialReferenceOutOfScope`), Test Connect, browse and resolve — #815, see `docs/security.md` §"Credential references are namespaced per driver". Never store a cleartext key. | | `GalaxyMxAccessOptions` (`MxAccess`) | `ClientName`; `PublishingIntervalMs` (`1000`); `WriteUserId` (`0` = anonymous); `EventPumpChannelCapacity` (`50000`) | MXAccess client identity + tuning. `ClientName` **must be unique per OtOpcUa instance** (redundancy pairs enforce this). | | `GalaxyRepositoryOptions` (`Repository`) | `DiscoverPageSize` (`5000`); `WatchDeployEvents` (`true`) | Galaxy Repository browse paging + deploy-event watching. | | `GalaxyReconnectOptions` (`Reconnect`) | `InitialBackoffMs` (`500`); `MaxBackoffMs` (`30000`); `ReplayOnSessionLost` (`true`) | In-driver reconnect-supervisor backoff. | diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 3cc617714..39a97d5b6 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -81,7 +81,7 @@ Project root files: `DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are: -- **`Gateway`** — endpoint, API key secret ref, TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. +- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815, [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime. - **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric). - **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`. - **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag). @@ -103,8 +103,10 @@ Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowle not silently downgraded to the fallback. 2. **The driver's fallback account** — `AlarmAck.FallbackUser` / `FallbackDomain` / `FallbackPasswordSecretRef` in `DriverConfig`, used by keys without an identity and by anonymous - sessions. The password ref must be `secret:NAME` / `${secret:NAME}` (production), `env:NAME`, - `file:PATH` or `dev:VALUE`; a bare literal is refused so a password never sits in DriverConfig. + sessions. The password ref must be `secret:galaxy/NAME` / `secret:otopcua/galaxy/NAME` (or the `${secret:…}` + spelling; production), `env:OTOPCUA_GALAXY_NAME` / `env:GALAXY_MXGW_NAME`, or `dev:VALUE`; a bare literal, a + `file:` reference or a reference outside the Galaxy namespace is refused (#815) — the password is sent to the + authored gateway endpoint, so it must not be able to name another subsystem's secret. 3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway health). How the client learns of it (#760): diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index 978088a90..115bb6b35 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -91,7 +91,11 @@ Host at their own machine. Anything outside the namespace is refused by **every* (`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path** (`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the -deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. +deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. Since #815 the parser and resolver are +the shared `CredentialReferences` / `DriverCredentialPolicy` (`Core.Abstractions`) that Galaxy and OpcUaClient use +too, and Test Connect / live browse also refuse an out-of-namespace reference before any probe or browser runs; a +prefix in another casing (`SECRET:`) is refused, and `${secret:heidenhain/}` is accepted as a spelling of +`secret:heidenhain/`. Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 8b4ad31b5..2e2b2b92a 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -81,6 +81,12 @@ for every field (security policy/mode, auth type, session timeout, keep-alive, browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save. +**Credentials (#815).** `Password` / `UserCertificatePassword` are a literal, `secret:opcuaclient/` (the node's +secret store) or `env:OTOPCUA_OPCUACLIENT_`. They are resolved on the node and sent to the configured endpoint, +so any other `secret:`/`env:` reference (and `file:`) is refused at save, deploy (`CredentialReferenceOutOfScope`), Test +Connect, browse and again at session open, before anything is read. Migrate an existing `secret:` by re-creating +the secret as `opcuaclient/`. See [`docs/security.md`](../security.md) §"Credential references are namespaced per driver". + `ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing). diff --git a/docs/security.md b/docs/security.md index be8288f35..20e59cfa4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1144,6 +1144,67 @@ Supporting hygiene, none of which is the control: If a deployment needs the browse surface narrower than "anyone who may author config", the lever is the page policy (and the LDAP group→role mapping behind it), not the browser. +#### Credential references are namespaced per driver (#815) + +The outbound-connect capability above has a sharper edge whenever the config carries a **credential +reference**: a Designer authors both the endpoint *and* the reference, and the node resolves the reference +and sends the value to that endpoint — on Test Connect, on a live browse, and at runtime. Before #815 an +unscoped reference let anyone who may author config exfiltrate any value the admin node could read, with no +deploy: point a Galaxy `Gateway.Endpoint` at their own host, set `ApiKeySecretRef` to +`env:ConnectionStrings__ConfigDb` (or `env:ServerHistorian__ApiKey`, or `file:`), and click Test +Connect / browse; or set an OpcUaClient `Password` to `secret:`. + +Every operator-authored reference is now confined to its driver's namespace — one table, +`DriverCredentialPolicy` (`Core.Abstractions`), one parser/resolver, `CredentialReferences`: + +| Driver | Fields | Allowed references | Literal | +|---|---|---|---| +| `GalaxyMxGateway` | `Gateway.ApiKeySecretRef` | `secret:galaxy/`, `secret:otopcua/galaxy/`, `env:OTOPCUA_GALAXY_`, legacy `env:GALAXY_MXGW_` | allowed (warns), `dev:` | +| `GalaxyMxGateway` | `AlarmAck.FallbackPasswordSecretRef` | same | `dev:` only | +| `OpcUaClient` | `Password`, `UserCertificatePassword` | `secret:opcuaclient/`, `env:OTOPCUA_OPCUACLIENT_` | allowed | +| `Heidenhain` | `InspectPassword`, `DncPassword`, `DataPassword` | `secret:heidenhain/`, `env:OTOPCUA_HEIDENHAIN_` (#803) | refused | + +Rules (all drivers): the prefixes are lower-case and exact (`SECRET:` / `Env:` are refused, never treated +as a literal); `${secret:}` is accepted as a spelling of `secret:`; secret names are +`[A-Za-z0-9._-]` segments with no empty, `.` or `..` segment; the variable suffix is `[A-Z0-9_]+`; **`file:` is +refused everywhere** (Galaxy used to read any file the node could open). A field is matched at any depth of +the DriverConfig / DeviceConfig, case-insensitively, as the drivers bind it. + +Enforced, fail-closed, at every seam — the earlier ones exist for a clear message, the last one is the guarantee: + +1. **AdminUI save** — `DriverConfigSaveGate` (`RawTreeService` driver/device create + update): the config is + never stored. +2. **Deploy gate** — `CredentialReferenceDeployChecks`, code **`CredentialReferenceOutOfScope`** (Heidenhain keeps + `HeidenhainCredentialPersisted`). +3. **Test Connect** — `AdminOperationsActor` refuses before any probe runs (`"Test Connect refused: …"`). +4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens. +5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` (driver + + browser) and `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope + **before** any secret-store or environment read. + +Messages name the field and the allowed forms, never the value. Out of scope by design: `${secret:}` tokens in +`appsettings.json` (host config, not operator-authored), and the `Sql` driver's `connectionStringRef`, which is +already a fixed prefix (`Sql__ConnectionStrings__`) whose value carries its own server — a Designer can +choose *which* provisioned database a ref names, but cannot redirect its credentials to another host. + +**Migration (existing configs).** After the upgrade, a stored config with an out-of-namespace reference fails +its next deploy (`CredentialReferenceOutOfScope`), its Test Connect / browse, and — for a running driver — its +next (re)initialise, which faults the driver with the same message. To migrate: + +- **Galaxy API key** — `env:GALAXY_MXGW_API_KEY` (the docker-dev rig and docs' name) keeps working. Any other + variable: rename it to `OTOPCUA_GALAXY_` on every node that runs the driver *and* the admin nodes + (Test Connect / browse), and set `env:OTOPCUA_GALAXY_`. A store secret already named `galaxy/…` keeps + working; otherwise re-create it as `galaxy//apikey` (`/admin/secrets` or the `secret` CLI). A + `file:` key must move into the store or a namespaced variable. +- **Galaxy fallback ack password** — the documented `secret:otopcua/galaxy/…` name keeps working; `env:` / `file:` + references move as above. +- **OpcUaClient** — re-create `secret:` as `opcuaclient/` (and update the field), or move an + environment value to `OTOPCUA_OPCUACLIENT_`. Literal passwords are unaffected. + +Residual (accepted): within one namespace, a Designer can still point one driver instance at another +instance's secret (e.g. `secret:galaxy//apikey`) — the value then reaches the endpoint the Designer +chose. Per-instance scoping is a possible follow-up. + ### Role grant source Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI. diff --git a/docs/v2/driver-specs.md b/docs/v2/driver-specs.md index 2501b1a8c..59bcc45e4 100644 --- a/docs/v2/driver-specs.md +++ b/docs/v2/driver-specs.md @@ -51,7 +51,7 @@ Per `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs`: { "Gateway": { "Endpoint": "http://localhost:5120", - "ApiKeySecretRef": "secret:galaxy-gw-api-key", + "ApiKeySecretRef": "secret:galaxy/gw/apikey", "UseTls": true, "CaCertificatePath": null, "ConnectTimeoutSeconds": 10, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs index 56fff75ae..bca223edd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs @@ -64,9 +64,11 @@ public sealed record GalaxyDriverOptions( /// /// /// -/// must be a reference — secret:NAME (production), -/// env:NAME, file:PATH or dev:VALUE (rigs only). A bare literal is rejected so a -/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB. +/// must be a Galaxy-namespaced reference (lmxopcua#815) — +/// secret:galaxy/NAME / secret:otopcua/galaxy/NAME (production), env:OTOPCUA_GALAXY_NAME +/// / env:GALAXY_MXGW_NAME, or dev:VALUE (rigs only). A bare literal, file: and any other +/// reference are rejected so a Galaxy password never sits in cleartext in the config DB and the field can +/// never name another subsystem's secret. /// /// /// Galaxy user the fallback acknowledges as; null/empty = no fallback. @@ -84,20 +86,16 @@ public sealed record GalaxyAlarmAckOptions( /// /// Connection details for the MxAccess gateway. is -/// resolved by at InitializeAsync time. Five forms -/// supported, in priority order: +/// resolved by at InitializeAsync time. References are confined +/// to the Galaxy namespace (lmxopcua#815 — the key is sent to the authored Endpoint): /// -/// env:NAME — read from an environment variable (recommended for -/// production; the central config DB holds only the indirection, not the key). -/// file:PATH — read from an ACL'd file outside the repo. -/// secret:NAME — resolved through the shared ZB.MOM.WW.Secrets +/// secret:galaxy/NAME (or secret:otopcua/galaxy/NAME) — the shared ZB.MOM.WW.Secrets /// encrypted store; fail-closed if absent (the production path). -/// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; -/// no startup warning. -/// Anything else — treated as a literal cleartext API key for back-compat. -/// The resolver emits a Warning at startup so an operator who accidentally -/// committed a cleartext key sees it; production should migrate to env: -/// or file:. +/// env:OTOPCUA_GALAXY_NAME (or legacy env:GALAXY_MXGW_NAME) — an environment variable. +/// dev:KEY — explicit cleartext opt-in for dev rigs / parity tests; no startup warning. +/// Any other unprefixed string — a literal cleartext API key for back-compat, with a startup +/// Warning. +/// Any other secret:/env: reference, and every file: reference — refused. /// /// // PR 6.5 tuning notes: