Skip to content

ADR 0087: A session credential is named by the record and read where the session opens

Status: Accepted Date: 2026-09-02 Issue: #1912 Related: #873 and #919 (the gateway's discovery path, which already had the right shape), #941 (the bench controller that found the gap), #1913 (the wizard emitting what it verified), #1762 (why a probe is never rolled by the operator)

Context

An OPC UA IOModule carried its session security in spec.options, a flat string map. Two of those keys were username and password. Three more, certFile, keyFile and caFile, named files inside the pod.

The password was in the clear on a kind that anyone with list can read. It was what dcs io list printed. It was in every backup and in every kubectl get -o yaml pasted into a ticket. The unit controller then copied spec.options verbatim into the <unit>-iomodules ConfigMap, so the same password sat on a second kind with the same audience.

The three file keys were worse in a quieter way. Nothing put a file at those paths. The unit-runtime pod mounts its data directory, the MQTT and mTLS certificates and two ConfigMaps. The io-probe mounts its ConfigMap and the optional mTLS Secret. No chart value named an OPC UA client identity for either. The gateway had one, provisioned for discovery under #919. The runtime that does the writing did not. And there was no server pin at all: server trust on the runtime path was a whole CA file, on a path nothing mounted.

So the only endpoint the runtime could open was one advertising None/None with anonymous authentication. That is the posture ADR 0057 refuses to default to, and it is a posture a correctly provisioned CODESYS controller does not offer at all. The bench CC100 advertises twelve endpoints, every one of them Sign or SignAndEncrypt with a username token. The gateway's discovery wizard could reach it, verify a tuple, read and write. The IOModule it emitted could not open a session, and the phase leg of #941 stopped there.

The Unit's serviceBinding path had already solved this for the service plane. Its security block names a policy, a mode, an auth mode, a credentialsRef Secret and a server certificate pin, and one resolver in internal/servicebinding turns that into a session for the phase controller and the gateway alike. It was scoped to MTP and PA-DIM services. Channel I/O had nothing of the kind.

Decision

A credential is named by the record, held by a Secret, projected into the pods that open the session, and read there. It travels on no other road.

Concretely:

  • IOModule.spec.security is the same OPCUASecurityConfig struct Unit.spec.serviceBinding.security uses, field for field. The wizard, the docs and the operator's own memory carry one shape.
  • The physical operator projects the named Secret into the unit-runtime pod and the io-probe pod as an optional Secret volume, one directory per Secret under /etc/dcs/opcua-credentials, one file per key. The iomodules.json both pods read carries the Secret's name and never its contents. The password does not move from one readable kind to another.
  • The driver reads the projection at construction, validates the client identity the way the gateway's discovery path does (a parseable pair, the key matching the certificate, the certificate inside its window), and refuses with a sentence naming the Secret and the key when it cannot. On the typed road it discovers the server's endpoints, opens the channel against the one advertising exactly the requested tuple, and checks the pin before a session exists. This is ADR 0057's rule carried one hop further: a session is opened over the posture that was declared, or it is not opened.
  • The deprecated keys are not removed. A module authored on them is a running plant, and a plant does not stop on upgrade. They are reported by CredentialsSecured=False/PlaintextInSpec, the gateway never serves the password in either direction, the form no longer offers the fields, and the deployment can declare them refused with ioSecurity.refusePlaintextCredentials. That is the ADR 0085 shape: the deployment declares, and the product refuses where the act happens, which is driver construction in both pods.
  • The CRD refuses spec.security beside any of the seven legacy keys. Two declarations of one session's posture that could disagree is the thing ADR 0057 exists to prevent, so the overlap is refused at admission and again at construction.

Which pod is recreated, and which is not

A Secret volume cannot be added to a running pod. The two pods answer that differently, on purpose.

The unit-runtime pod is recreated when the set of Secrets its modules name changes. The pod serves one unit, the change is to that unit's own record, and the tag-map volume already recreates it under the same rule.

The io-probe pod is not. A probe serves every module on its controller or in its namespace, and a recreate opens a read gap on all of them. That gap can cross a declared spec.failSafe.timeout and put a device into its fail-safe. That is the #1762 ruling on image drift, and it holds here for the same reason. The module reports CredentialsSecured=False/ProbeNotProjected naming the restart, and a person decides.

Where the condition is served

CredentialsSecured reaches every surface. The gateway serves it as a credentials verdict beside the verbatim condition, dcs io list <name> prints it under the security block, and the module detail page paints it. A condition only kubectl can read is #1768.

Alternatives Considered

Resolve the Secret in the operator and write the credentials into the ConfigMap. One line in the reconciler, no pod change. Rejected because it moves the credential from a kind anyone with list can read into a second kind anyone with list can read. That is the defect under a new name.

Give the runtime and the probe a Kubernetes client and let them read the Secret. Rejected because the device nodes run with hostNetwork and no apiserver credentials by design, and the smallest RBAC that reads one Secret is a service account on every device node that reads every Secret in the namespace. The projection gives each pod exactly the Secrets its record names, and the kubelet enforces it.

Option keys naming the Secret, in spec.options. The wizard proposed credentialsRef and serverCertSha256Pin as string options. Rejected because a flat map has no schema: the CRD could not require a Secret for a username token, could not refuse the overlap with the password key, and could not carry a namespace refusal. A typed field does all three at admission, and it is the shape the Unit already has.

Remove the deprecated keys outright. Rejected for ADR 0068's reason and ADR 0085's: a running module would stop on upgrade over a field nobody asked to change. Reporting plus a declared refusal gives the same end state on the deployment's schedule.

Recreate the io-probe when its Secret set changes. Rejected on the #1762 ruling. The gap it opens is on devices whose record did not change.

Consequences

  • An OPC UA IOModule can now name a Secret, and the unit runtime and io-probe open a SignAndEncrypt session with a username token and a pinned server certificate from it. The #941 phase leg over the bench controller is unblocked on this side. #1913 makes the wizard emit the block.
  • A module carrying username or password in spec.options reports CredentialsSecured=False/PlaintextInSpec on every surface and keeps its session. A deployment that sets ioSecurity.refusePlaintextCredentials refuses the driver in both pods from then on.
  • The gateway drops password from every IOModule it serves and carries the existing one forward on an update that does not name it, so a client that never saw the password cannot strip it by handing back what it was served. An update that adds spec.security drops it, which is the migration.
  • The IOModule form no longer offers the five credential keys. A module still carrying them shows them under Additional Options, and the password is not there to show.
  • A rotated Secret reaches the pods through the kubelet's projection sync, but the driver read the files at construction. Today the new material is picked up when the driver is rebuilt: a config change, a runtime restart, or a probe restart. Reading at every connect is the follow-up, and ADR 0061's two-account window is what a rotation of the server-side user needs on top of it.
  • internal/servicebinding and the driver now decide which keys a tuple needs through one function in pkg/opcuaclient. The two sides of the product cannot accept and refuse the same Secret.
  • The cross-namespace refusal is spelled credentialsRef.__namespace__ in its CEL rule, because namespace is a reserved word in the CEL lexer and the apiserver exposes such a property under the escaped name. The v0.7.3 CRD spelled it .namespace: envtest at 1.36 and 1.33 admit that, a 1.31 apiserver refuses the whole CRD with undefined field 'namespace', and the bench re-run on a 1.31 kind node is where it surfaced. make lint-crd-cel-reserved now holds the spelling on every rule in the tree, since the version envtest runs is the newest one and the chart is declared for 1.28 and up.
  • The bench re-run itself, on 2026-09-03 against the CC100 from a kind stack on the v0.7.3 images: the physical operator projected the Secret into the unit-runtime and io-probe pods, both opened Basic256Sha256 / SignAndEncrypt with a username token and the pinned server certificate from it, the module reported CredentialsSecured=True/SecretResolved and went Online, and a batch drove the controller's run lamp and AO1 through a phase, read back from the controller by a second client. The record is on #1912.