Skip to content

ADR 0062: The read tier is confined to reads

Status: Accepted Date: 2026-08-15 Issue: #1551 Related: ADR 0005 (the role mechanism), ADR 0024 (action-level refinements)

Context

The Security Operations page told a customer standing up a SIEM collector to give it a dcs-operator identity and then wrap that credential in an egress proxy rule allowing only GET. The page said why: no shipped role was read-only, and dcs-operator was the cheapest grant that could read the audit API. That role carries operate. The operate tier is batch commands, ISA-88 state commands, tag writes and alarm acknowledgement. Least privilege for an identity that only observes rested on a network appliance, and the authorization model had nothing to say about it.

The role was expressible all along. ParseRoles accepts any subset of the permission vocabulary, so siem-collector: [read] was always a valid roles file. What made that unattractive is that a non-empty gateway.auth.roles replaces the shipped table outright, so a plant wanting one read-only identity had to re-declare all five shipped roles to get it and then keep that copy in step with the product.

The capture stack had already hit this and worked around it locally. The LDAP overlay behind the FUXA integration take declares dcs-scada-readonly: [read], and the comment above it states the gap in the product's own words: every role from dcs-operator up carries operate, and a caller in no group at all holds nothing whatever, read included. A frontend commissioned to render the plant without commanding it therefore had no shipped grant to run as.

Shipping the role is one line. The question that made it a decision worth recording is what the role's name promises. A role called dcs-viewer holding read alone is a claim about the read tier, and that claim was false. Eight OPC UA discovery routes sat at PermRead, registered there on the reasoning written above them, that they are transient and mutate no CR.

Mutating no CR is not the same as reading. The endpoint arrives in the request body, so the gateway opens an outbound OPC UA session to whatever host and port the caller names. The browse, search and read verbs then pull an address space and live values back over it. That capability belongs to the Device Discovery wizard, which is their only caller in the product and lives in the engineer-gated /system app. A page gate is display, so it was gating nothing underneath: every authenticated identity could drive the gateway's discovery client. The sibling discovery:write had been engineer since it landed.

Decision

Three parts, and the third is what keeps the first two true.

The shipped role table carries a read-only role. dcs-viewer grants read and nothing else. It is the bottom of the cumulative ladder and no role inherits from it, since every other role already grants what it grants. Least privilege for a SIEM collector, a reporting job, a read-only auditor login, or a frontend trusted with reads before writes is now a group assignment.

The OPC UA discovery routes move to engineer. They mutate no CR and they reach out of the cluster on a caller-supplied address, which is an engineering capability. This is a permission change on eight shipped routes, and it is deliberate: their only caller in the product already required engineer to reach the screen they are called from.

Every non-GET route at PermRead declares why it is still a read. scripts/lint-read-tier.sh reads the registrations out of routes.go and requires a row in scripts/.read-tier-writes.tsv carrying one of three verdicts. dry-run persists nothing and reaches nothing outside the cluster, and is a POST because the request needs a body. handler-gated means the route gate is deliberately the baseline so the handler's own check runs and its refusal is audited, and the row names a file:symbol the gate greps for. flag-gated means the route is unreachable unless the deployment opts in, and the row names the flag the same way. Four routes hold rows today. There is no allowlist. A fourth verdict would mean "this one writes and we would rather not say so", which is the state the tier was already in.

The gate also fails if ViewerGroup: {PermRead} leaves the shipped table. The invariant exists for that role, and without the role it would be guarding a promise nobody makes.

Alternatives Considered

Document the roles-file recipe and change no code. A deployment can write siem-collector: [read] today, so the page could have been corrected without shipping anything. Rejected because the recipe would have been wrong in the same way the page was: a role holding read alone could still drive OPC UA discovery, so the document would have had to describe an ADR 0024 deny list to be accurate, and a deny list is a per-deployment delta over a product defect.

Ship dcs-viewer and leave the discovery tier alone. Cheaper and non-breaking. Rejected because it ships a role whose name is false on the day it lands, which is the shape #1565 found across the mutation surface: friction that reads like enforcement.

Move signature:mint and site:heal up as well, so read is GET-only. Rejected for signature:mint on a real cost. The route is gated at read so the handler's own supervise check runs, which is what writes the refusal to the audit trail (21 CFR Part 11 §11.10). Raising the route gate would move the refusal above the audit point and lose the record. site:heal is refused outright unless demo auto-provisioning is on. Both are better served by writing the reason down than by a tier that hides it.

Make the gate a Go test. Rejected for consistency with the route-reading gates already in the tree. Those parse routes.go textually, so the check is about the registration itself. A Go test would report what a handler happens to do when a test calls it, which is a different question.

Consequences

A deployment that upgrades gets one new group name to know about, and nothing it had before changes. The five existing roles keep their permissions exactly.

Any identity that reached the OPC UA discovery routes without holding engineer loses that access. In the shipped UI nothing does, because the Device Discovery wizard is only reachable from an engineer-gated app. A deployment driving those routes from its own tooling under an operator credential has to move that credential up, or grant discovery:browse to the role through an ADR 0024 allow entry. That entry keeps the grant a reviewable line in its roles file.

The Security Operations page drops the egress-proxy requirement. Confining a collector credential to GET at a proxy stays good practice as defence in depth. It is no longer what the read-only property rests on, so a misconfigured proxy no longer silently hands a SIEM the ability to command the plant.

A future route registered at PermRead with a mutating verb fails the gate before it merges. The author then either moves the tier or writes down which of the three verdicts applies, which is the moment this record exists to create.