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:
+43
-12
@@ -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, > 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
|
||||
|
||||
Reference in New Issue
Block a user