Security Boundary Design for Water Utility Compliance Automation
Security boundary design establishes the controlled architectural perimeter that isolates operational technology (OT) telemetry from the information technology (IT) environment where Safe Drinking Water Act (SDWA) compliance reporting runs. This topic sits within the Core Architecture & SDWA Compliance Taxonomy and is written for the water utility operations staff who own the control network, the environmental compliance teams who sign the reports, and the municipal Python developers who build the automation between them. The boundary is not an optional hardening step: for municipal water systems it is simultaneously a cybersecurity control that keeps query load and lateral movement away from live process control, and the evidentiary spine that lets an automated pipeline prove data lineage from a sensor register all the way to an EPA submission. The workflow below moves telemetry outward through segmentation, protocol translation, validation, and immutable logging without ever routing a command back into the control loop.
Regulatory & Protocol Foundation
Two distinct bodies of requirement converge on this perimeter. On the security side, the controlling reference is NIST SP 800-82 Rev. 3, the federal guidance for securing operational technology, which prescribes network segmentation, a demilitarized zone (DMZ) between control and enterprise networks, least-privilege access, and one-directional data flows out of the control zone. On the compliance side, the EPA Safe Drinking Water Act and its implementing regulations in 40 CFR Part 141 impose recordkeeping and evidentiary obligations: a utility under primacy-agency review must be able to reconstruct exactly which raw register value produced which reported result, through which transformation, at which timestamp. The boundary is where these two mandates meet — it must protect the control network and preserve an unbroken chain of custody at the same time.
The protocol constraints follow from where the data originates. Industrial payloads arrive as DNP3, Modbus TCP, or OPC UA frames on the control network, and none of these formats is safe to forward directly into a reporting environment. Modbus in particular carries no authentication and no notion of read-only access at the wire level, so the boundary — not the device — must guarantee that only measurement data, never a writable coil or command, ever crosses outward. The same quality-flag vocabulary produced when parsing Modbus registers for turbidity sensors is preserved across the perimeter so that a single provenance taxonomy holds from the wire to the report. Because the boundary consumes live SCADA telemetry, it must also respect the tight control loops that govern pump actuation, valve positioning, and chemical dosing: any added polling load or latency introduced on the OT side is a treatment risk, which is why egress is unidirectional and the compliance pipeline is a strictly passive consumer of the SCADA data ingestion stream.
Architecture & Design Decisions
The reference topology places three zones in series — an OT zone containing the control network, a DMZ where translation and validation occur, and an IT zone where reporting and submission run. The central design decision is directionality: data moves outward only, through a data diode (a hardware device that is physically incapable of carrying a return signal) or, where a diode is impractical, a hardened API proxy that terminates all inbound connections and re-originates a fresh outbound-only request. Nothing routes back toward the control network. This single constraint eliminates the largest class of OT risk — an attacker or a misconfigured job reaching through the reporting tier to issue a control command.
A second decision concerns what is allowed to cross. The DMZ never forwards a raw industrial frame. Payloads are decoded, reduced to read-only measurement fields, stripped of any protocol metadata that could carry a command, and re-serialized into a neutral structured format such as JSON or Parquet before egress. The record that leaves the DMZ conforms to the same enriched compliance contract used across the taxonomy, so that a value arriving at the boundary already carries a contaminant_id, a method_code, a UTC sample_ts, and a quality_flag. Threshold context for that value is resolved downstream against the SDWA MCL Reference Mapping, and the temporal window it belongs to is governed by Monitoring Frequency Scheduling; the boundary’s job is only to deliver a clean, attributable record into that pipeline.
The third decision is access control on both faces of the DMZ. Ingress from OT and egress to IT are constrained by strict access control lists (ACLs), mutual TLS (mTLS), and narrowly scoped API tokens, so that even a compromised DMZ process cannot widen its own reach. The concrete continuous-verification patterns for this perimeter — per-flow authentication, device posture checks, and micro-segmentation — are detailed in Implementing Zero-Trust Boundaries for SCADA Networks.
Phase-by-Phase Implementation
Building the boundary proceeds in four phases, each of which produces an artifact the next phase depends on: a segmented network, a sanitizing egress translator, a validating ingress gate, and an immutable audit trail.
Phase 1 — Segment the network and define the zones
Segmentation is enforced before any code runs. The control network, the DMZ, and the enterprise reporting network are placed in separate VLANs or physical segments, and the only permitted path between OT and DMZ is the diode or proxy. Capture the segmentation as version-controlled configuration rather than hand-edited firewall state so that a boundary change is reviewable and reversible.
Implementation steps:
- Assign each zone a dedicated subnet and default-deny posture.
- Permit exactly one egress flow from OT to the DMZ ingress endpoint; deny all return traffic.
- Express the allowed flows as data and load them at deploy time.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class FlowRule:
"""A single permitted, unidirectional network flow across the boundary."""
src_zone: str
dst_zone: str
dst_port: int
protocol: str
bidirectional: bool = False
# Only OT->DMZ (egress) and DMZ->IT (validated records) are ever allowed.
BOUNDARY_FLOWS: tuple[FlowRule, ...] = (
FlowRule(src_zone="ot", dst_zone="dmz", dst_port=5020, protocol="mtls"),
FlowRule(src_zone="dmz", dst_zone="it", dst_port=8443, protocol="mtls"),
)
def assert_flow_allowed(src: str, dst: str, port: int) -> None:
"""Fail closed: reject any flow not explicitly enumerated as one-way egress."""
for rule in BOUNDARY_FLOWS:
if rule.src_zone == src and rule.dst_zone == dst and rule.dst_port == port:
if rule.bidirectional:
raise ValueError(f"Bidirectional flow forbidden across boundary: {rule}")
return
raise PermissionError(f"Flow {src}->{dst}:{port} is not permitted by BOUNDARY_FLOWS")
Phase 2 — Translate and sanitize at the egress point
The egress translator is the only component that touches raw industrial payloads. It decodes the frame, keeps only measurement fields, and emits a neutral record. Critically, it never exposes a write path back to the device — the client is opened read-only and the decoded output is a plain immutable mapping.
Implementation steps:
- Read the measurement registers with a read-only client call.
- Reject any frame carrying writable or command fields.
- Serialize the sanitized measurement into the neutral compliance record shape.
import math
from datetime import datetime, timezone
from typing import Any
def sanitize_measurement(
device_id: str,
contaminant_id: str,
raw_value: float,
method_code: str,
) -> dict[str, Any]:
"""Reduce a decoded industrial reading to a read-only, egress-safe record.
Any non-finite reading is flagged rather than forwarded as a real value,
preserving the GOOD/SUSPECT/BAD quality vocabulary used across the pipeline.
"""
if math.isnan(raw_value) or math.isinf(raw_value):
quality = "BAD"
value = None
else:
quality = "GOOD"
value = round(raw_value, 4)
return {
"device_id": device_id,
"contaminant_id": contaminant_id,
"value": value,
"method_code": method_code,
"sample_ts": datetime.now(timezone.utc).isoformat(),
"quality_flag": quality,
}
Phase 3 — Validate at the DMZ ingress and quarantine failures
Once a record reaches the DMZ, it is validated before it is allowed toward the reporting tier. Schema enforcement with a declarative library rejects malformed or out-of-range records at the boundary rather than deep inside a report. Records that fail are routed to a quarantine buffer for human review, never silently dropped and never forwarded.
Implementation steps:
- Enforce field types, the 64-character
source_hash, and a timezone-aware UTC timestamp. - Confirm the reading is within physically plausible bounds for its contaminant.
- Send anything that fails to quarantine with a structured error payload.
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError, field_validator
class BoundaryRecord(BaseModel):
device_id: str
contaminant_id: str
value: float
method_code: str
sample_ts: datetime
quality_flag: str
source_hash: str = Field(min_length=64, max_length=64)
@field_validator("sample_ts")
@classmethod
def _must_be_utc(cls, ts: datetime) -> datetime:
if ts.tzinfo is None or ts.utcoffset() is None:
raise ValueError("sample_ts must be timezone-aware UTC")
return ts
def admit_or_quarantine(payload: dict) -> tuple[BoundaryRecord | None, dict | None]:
"""Return (record, None) if the payload is admissible, else (None, error)."""
try:
record = BoundaryRecord(**payload)
except ValidationError as exc:
return None, {"quarantined": payload, "errors": exc.errors()}
if record.quality_flag not in {"GOOD", "INTERPOLATED"}:
return None, {"quarantined": payload, "errors": [{"msg": "ineligible quality flag"}]}
return record, None
Phase 4 — Seal the record with an immutable audit trail
Every record that clears validation is sealed into an append-only chain of custody. A SHA-256 digest of the raw payload — computed with the standard-library Python hashlib module — travels with the record as its source_hash, and each downstream transformation writes a new record referencing its input’s hash. Because the store is append-only, a correction is a superseding record, never an in-place edit, which preserves the original for review.
Implementation steps:
- Hash the raw payload at the moment it crosses the boundary.
- Bind that digest to the record as an immutable identifier.
- Append the sealed record to a write-once store; never mutate an existing row.
import hashlib
import json
from typing import Any
def seal_record(raw_payload: bytes, record: dict[str, Any]) -> dict[str, Any]:
"""Attach a SHA-256 digest of the raw payload and freeze the record."""
digest = hashlib.sha256(raw_payload).hexdigest()
sealed = {**record, "source_hash": digest}
return sealed
def append_only_write(sealed: dict[str, Any], ledger_path: str) -> None:
"""Append one immutable JSON line; opening in 'a' mode forbids overwrite."""
with open(ledger_path, "a", encoding="utf-8") as ledger:
ledger.write(json.dumps(sealed, sort_keys=True) + "\n")
Validation, Quality Flags & Edge Cases
The boundary maintains a small state machine per data flow so that an interruption never produces a silent gap in the compliance record. A flow is HEALTHY while validated records cross on schedule; it degrades to DEGRADED when validation failures or read errors appear within a bounded staleness window, and escalates to SEVERED once that window expires — at which point the reporting tier is told, explicitly, that no trustworthy telemetry is crossing rather than being left to infer freshness.
Several edge cases deserve explicit handling at the perimeter:
- Timezone and DST drift. Field devices frequently emit local wall-clock time. The boundary must normalize every
sample_tsto UTC on ingress, because a record stamped in a zone that observes daylight saving can otherwise appear to travel backward across a fall-back transition and corrupt an averaging window. Alignment to a single monotonic UTC axis is handled in concert with time-series alignment strategies. - Partial and duplicated frames. A diode or proxy restart can truncate a frame or replay one. Every record therefore carries a deterministic key and its
source_hash, so a replayed message overwrites its own prior result on an idempotent write instead of inflating a sample count. - Non-finite readings.
NaNandInfvalues from a faulting analyzer must be flaggedBADat translation time, never forwarded as a numeric value that could trigger a phantom exceedance. - Certificate expiry mid-window. An mTLS certificate that lapses during a reporting window severs the flow. The state machine surfaces this as
SEVEREDso it is visible to operators rather than presenting as a quiet data gap.
Deployment & Integration Patterns
The DMZ translation and validation services are best deployed as small, single-purpose containers with a read-only root filesystem, so that a compromised process cannot persist a foothold or rewrite its own code. Grant each container only the one egress flow it needs and mount the append-only ledger on a dedicated write volume. Because the boundary must never apply backpressure to the OT side — slowing the diode is not an option when the control loop is upstream — buffer bursts inside the DMZ using a message broker (for example a Kafka topic or an MQTT queue) and let the validation workers drain it at their own pace.
Long-running enrichment and reprocessing jobs that run behind the boundary should be dispatched through an asynchronous worker tier rather than blocking the ingress path; the async batch processing setup covers that pattern in depth. Once records clear the boundary and are sealed, they feed the Violation Detection Rule Engine, where exceedance logic and monitoring-gap detection turn attributable measurements into reportable compliance state. Standardized exceedance codes for those events are produced by Violation Code Classification.
Production Validation Checklist
Failure Modes & Gotchas
The single most consequential misconfiguration is a boundary that is one-directional in intent but bidirectional in fact. The most common cause is a “hardened” API proxy that, for convenience, accepts an inbound connection from the reporting tier and forwards it toward OT — turning the DMZ into a bridge and reopening exactly the lateral-movement path the architecture exists to close. It is easy to miss because compliance data still flows correctly outward; the return path is silent until it is abused. Catch it by testing the boundary from the wrong side: from an IT-zone host, attempt to open a connection to any OT address and confirm it is refused, and assert in code that no FlowRule is ever bidirectional.
A close second is a lapsed mTLS certificate that severs the flow mid-reporting-window and presents as an ordinary data gap. Because the boundary is an ongoing operational control rather than a one-time configuration, certificate expiration, PLC firmware updates, and topology changes each represent a moment where an unmanaged perimeter can reopen a path or drop telemetry. Schedule quarterly boundary audits against the NIST SP 800-82 Rev. 3 control baseline, automate certificate rotation ahead of expiry, and rehearse the partition-recovery runbook so compliance collection resumes within the defined RTO without manual intervention.
Related
- Core Architecture & SDWA Compliance Taxonomy — parent architecture and shared data contracts
- Implementing Zero-Trust Boundaries for SCADA Networks — continuous-verification patterns for this perimeter
- SDWA MCL Reference Mapping — threshold lookup for records crossing the boundary
- Monitoring Frequency Scheduling — temporal windows the boundary must not gap
- Violation Code Classification — standardized codes for downstream exceedances