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.

Outward compliance boundary workflow Four sequential phases move telemetry outward — segment the network, translate and sanitize at egress, validate and quarantine at ingress, then seal an immutable audit trail — each emitting the artifact the next phase depends on, with no command path returning to the control network. 1 · Segment 2 · Translate 3 · Validate 4 · Seal default-deny VLANs read-only record schema gate + quarantine SHA-256 · append-only No command path returns to the control network
The outward compliance workflow: four phases, each emitting the artifact the next depends on — and no path back toward control.

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.

Three-zone boundary reference architecture An OT control-network zone on the left connects through a one-way data diode into a DMZ that performs protocol translation and validation and holds failures in a quarantine buffer; validated records leave the DMZ to an IT reporting zone on the right. Both DMZ faces enforce mutual TLS and access-control lists, and the egress face adds a scoped token. No path returns toward the control network. One-way egress · data diode OT zone IT zone DMZ — compliance processing Translation Validation control network reporting · EPA decode · sanitize schema gate Quarantine buffer fail validated Ingress face Egress face mTLS ACL mTLS ACL scoped token
Three-zone topology: a one-way data diode into the DMZ, mutual-TLS and ACL controls on each face, and validated records leaving to the reporting zone.

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:

  1. Assign each zone a dedicated subnet and default-deny posture.
  2. Permit exactly one egress flow from OT to the DMZ ingress endpoint; deny all return traffic.
  3. 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:

  1. Read the measurement registers with a read-only client call.
  2. Reject any frame carrying writable or command fields.
  3. 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:

  1. Enforce field types, the 64-character source_hash, and a timezone-aware UTC timestamp.
  2. Confirm the reading is within physically plausible bounds for its contaminant.
  3. 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:

  1. Hash the raw payload at the moment it crosses the boundary.
  2. Bind that digest to the record as an immutable identifier.
  3. 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.

Boundary flow state machine A per-flow state machine. The flow starts HEALTHY. Validation failures or read errors within the staleness window move it to DEGRADED; sustained validated records return it to HEALTHY. If the staleness window expires the flow becomes SEVERED and the reporting tier is told no trustworthy telemetry is crossing; once the boundary recovers and records validate, it returns to HEALTHY. HEALTHY DEGRADED SEVERED on-schedule egress within staleness window reporting tier notified validation failures / read errors (in window) sustained validated records staleness window expires boundary recovered · records validated
Per-flow state machine: healthy egress, degraded within the staleness window, and severed once it expires — recovering only on sustained validated records.

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_ts to 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. NaN and Inf values from a faulting analyzer must be flagged BAD at 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 SEVERED so 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.