fix(heidenhain): refuse literal credentials and writable/mistyped tags at the save path (#803 review)

- Review 1: RawTreeService Create/UpdateDriver and Create/UpdateDevice refuse a Heidenhain
  config whose password fields are not blank / secret:heidenhain/... / env:OTOPCUA_HEIDENHAIN_...
  (DriverConfigSaveGate over HeidenhainOptionsParser.DescribeCredentialProblems: top level,
  Access, Devices[], case-insensitive keys; the value is never echoed), so a literal password
  never reaches the draft DB. The driver and device modals show the reason and disable Save.
- Review 8: CreateTag/UpdateTag/ImportTags (tag modal, manual entry, CSV import, browse commit)
  refuse a ReadWrite Heidenhain tag or a Tag.DataType that disagrees with what the driver
  publishes (HeidenhainTagRules: catalog type, Float64 => Double, or the binding's dataType).
  The tag modal and manual-entry grid fix the access level at Read and derive the data type from
  the address (RawTagRowDefaults). New deploy gate HeidenhainTagDataTypeMismatch.
- docs/drivers/Heidenhain.md: credential namespace convention + why, the save-path and deploy
  enforcement, optional-role credential behaviour, BadConfigurationError, backoff/ordering notes.

Tests: HeidenhainSavePathTests (service save path; red with the gates disabled) and new
DraftValidatorHeidenhainTests cases.
This commit is contained in:
Joseph Doherty
2026-11-10 23:54:32 -05:00
parent f7a3588b24
commit 54f58c583c
15 changed files with 427 additions and 33 deletions
+43 -12
View File
@@ -16,9 +16,14 @@ Three independent layers, each sufficient on its own for its concern:
1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every
OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`).
2. **No writable nodes.** The deploy gate `HeidenhainTagWritable` refuses a `ReadWrite` access level on any tag
under a Heidenhain driver, so no node is ever advertised writable. A `"writable": true` TagConfig key is
rejected by the tag reader and dropped by the typed editor.
2. **No writable nodes.** A `ReadWrite` access level is refused at **authoring** — `RawTreeService`
create/update/import (so the tag modal, manual entry, CSV import and browse commit), where the modal and the
manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no
node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by
the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed
address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the
save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older
(one rule, `HeidenhainTagRules`).
3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks
command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s
public surface is pinned by a reflection test) and no configuration knob that widens it.
@@ -70,12 +75,31 @@ Device config (one `/raw` Device per control, AdminUI **HeidenhainDeviceForm**):
`Port` (19000), optional per-device `InspectPassword`/`DncPassword`/`DataPassword` overrides. A device is validated
only once it carries tags, so an unconfigured default `Device1` does not fault the driver.
**Credentials.** A password field is blank, `secret:<name>` (the node's encrypted secret store, via
`ISecretResolver`) or `env:<VARIABLE>`. It is resolved **at each login** and never held in options or session
state. A literal is refused by the parser, both AdminUI forms and the deploy gate `HeidenhainCredentialPersisted`
(the message never echoes the value). Fail-closed: an absent secret / unset variable fails the login rather than
sending the reference text. Plain LSV2 is unencrypted — reach the control over an externally managed secure
network; there is no SSH subsystem.
**Credentials — namespaced references only.** A password field is one of:
| Form | Resolved from |
|---|---|
| blank | no password |
| `secret:heidenhain/<name>` (e.g. `secret:heidenhain/line3/inspect`; `[A-Za-z0-9._-]` segments, no `..`) | the node's encrypted secret store (`ISecretResolver`), same slash-scoped naming as `galaxy/<inst>/apikey` |
| `env:OTOPCUA_HEIDENHAIN_<NAME>` (exact upper-case prefix, `NAME` = `[A-Z0-9_]+`) | the process environment |
**Why namespaced:** the reference is resolved on the node (for Test Connect: on the admin node) and sent in
**cleartext** in `A_LG` to whatever Host the author configured. An unscoped reference would let anyone who can edit
a driver exfiltrate `env:ConnectionStrings__ConfigDb`, `env:ServerHistorian__ApiKey` or any other secret by pointing
Host at their own machine. Anything outside the namespace is refused by **every** layer: the parser
(`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time**
(`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.
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
connect (INSPECT is required). An unusable **optional** DNC/DATA credential — unresolvable, or a password the LSV2
login payload cannot carry (NUL, non-Latin-1, &gt; 247 bytes) — never fails the session: the login is not sent, the
reads that need that role read `BadConfigurationError` (capability `ConfigurationError`), and INSPECT telemetry
continues. Plain LSV2 is unencrypted — reach the control over an externally managed secure network; there is no
SSH subsystem.
## Addresses (TagConfig)
@@ -134,6 +158,7 @@ At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/
| Binding type mismatch | `BadTypeMismatch` |
| Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) |
| Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` |
| Unusable optional DNC/DATA credential, or a request the read-only policy refuses | `BadConfigurationError` / `ConfigurationError` — that role's reads only; never sent; the session stays up |
| Not yet acquired | `BadWaitingForInitialData` |
| Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` |
@@ -150,8 +175,14 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re
**generation**; a batch returning after its session was retired can never overwrite the outage invalidation.
- **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change,
so a control returning with identical readings still re-publishes Good.
- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`). Unlike the Java base, an
initial connect failure also retries (no manual restart).
- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`, clamped to 100 ms–10 min).
The backoff resets only after a session **published a poll batch** — a control that accepts login + version and
then drops is backed off, not re-dialled every `InitialDelayMs`. Unlike the Java base, an initial connect
failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off);
JSON-`null` config sections are normalised to defaults by the parser.
- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never
receives an older value after a newer one. A re-init disposes every control loop first; a late start of a
disposed loop is a no-op.
- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with
`LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and
decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one
@@ -174,7 +205,7 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no
- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet
parity suites.
- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional
`HEIDENHAIN_LSV2_PORT`, `HEIDENHAIN_LSV2_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
`HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a
driver end-to-end status poll. Record its output as .NET commissioning evidence.
## Hardware-gated — not yet proven