Security Boundary Configuration

On this page

A security boundary in a research grant automation stack is not a firewall rule — it is the single place where an untrusted payload becomes a trusted record. Grant documents arrive from sponsor portals, equipment manifests arrive from departmental LIMS feeds, personnel certifications arrive from HR exports, and every one of those sources is, until proven otherwise, hostile to the integrity of the system. The boundary is the layer that decides what crosses into the trusted zone, with what privileges, carrying what compliance classification, and leaving what audit evidence behind. This guide specifies how to configure that layer so the decision is deterministic, least-privilege by default, and reproducible under audit. It is anchored to the foundational principles set out in Core Architecture & Policy Mapping for Research Grants.

For university administrators, research compliance officers, Python automation developers, and laboratory managers, the design goal is direct: every record that enters the trusted zone has been validated against the active rule set, tagged with an explicit data classification, scoped to the credentials that may touch it, and anchored by a field-level hash that an auditor can recompute months later. Nothing crosses on trust. A re-poll of the same payload converges to the same committed state; a malformed or over-privileged payload is diverted to a quarantine queue with an explicit reason and never contaminates downstream systems.

Problem Framing: The Trust-Zone Gap

The recurring failure is treating ingress as a transport problem rather than a policy problem. A sync job authenticates to a sponsor API, pulls a batch, and writes it straight into the grants database because “the connection is encrypted.” Transport security says nothing about whether the payload is well-formed, whether the calling service account is allowed to write that grant’s records, or whether the row carries human-subjects data that must never sit in an unencrypted column. The connection is secure and the data is still wrong.

A security boundary closes that gap by making three guarantees explicit and enforced in code rather than assumed. First, classification: every field that crosses is tagged with a handling class — controlled unclassified information (CUI), hazardous-material inventory, human-subjects data, or public — and that tag determines storage and access for the rest of the record’s life. Second, least privilege: an automated job inherits exactly the scope of its service account and cannot escalate; a script that syncs Lab A’s inventory cannot write Lab B’s records even if the payload claims it should. Third, auditability: the boundary records why each record was accepted or rejected, and produces a hash an auditor can reproduce. This guide builds those guarantees in layers — the policy constraints that bound them, the canonical schema they operate on, the idempotent implementation that enforces them, the integration contracts with adjacent systems, and the verification and recovery procedures that keep the whole thing defensible. The boundary consumes its rules from the University Policy Mapping Frameworks and protects the records owned by the Grant Lifecycle Architecture Design; it is the subsystem that owns what is allowed to enter the trusted zone, with what privilege, and under what classification.

Policy Constraints on Boundary Enforcement

The boundary does not invent its rules. It consumes a versioned predicate set published by the policy mapping layer and applies the subset that governs ingress and access. What the boundary owns is which control applies to which classification of data — the translation of regulation into machine-readable predicates lives upstream, and the boundary records the rule version it evaluated against so a decision can be replayed.

  • NIH Grants Policy Statement governs human-subjects and protected-health classifications. Any field tagged HUMAN_SUBJECTS at the boundary must be stored encrypted at rest and is readable only by service accounts holding the phi:read scope; the boundary rejects a write that would persist such a field in a public column.
  • NSF PAPPG and 2 CFR 200 (Uniform Guidance) set the controls for financial and award-identity fields. Cost and indirect-rate fields are classified CUI and gated by allowable-cost predicates before they may be committed against an active award.
  • OSHA 29 CFR 1910.1450 (Laboratory Standard) and EPA RCRA (40 CFR Part 262) apply when a payload carries chemical or hazardous-material inventory. These records are classified HAZMAT, and a boundary write triggers an inventory-reconciliation predicate that links to the downstream Equipment Calibration & Lab Inventory Tracking domain before the record is trusted.
  • NIST SP 800-53 Rev. 5 access controls (AC-3, AC-6, AC-17) express as the least-privilege scoping model itself: mutual-TLS identity on every cross-boundary call, scope checks on every write, and key rotation on the signing material that anchors the audit hash.

Two rules hold across every classification. Policy is evaluated before a record is committed, never after — a rejected record never reaches the trusted zone and the rejection is logged. And a policy version change never mutates an already-committed record; the new rule version affects only future boundary decisions, which is what keeps historical audit hashes reproducible. When a degraded primary route forces traffic onto a backup path, the same boundary predicates still apply — the routing logic for that case is specified in the Fallback Routing Protocols.

Data Schema & Field Mapping

The boundary operates on one canonical ingress record and an append-only decision ledger. Heterogeneous source payloads — an eRA Commons export, a Research.gov feed, a LIMS manifest, an HR certification extract — are mapped to this canonical shape at the moment of ingress; the wire-level transport rules for those source connections are detailed in Configuring secure API boundaries for research data sync. The canonical schema below is the contract every source must be mapped onto before validation runs.

Canonical IngressRecord schema and the append-only BoundaryLedger it feeds Three heterogeneous sources — a sponsor portal, a departmental LIMS feed, and an HR certification export — are mapped onto one canonical IngressRecord carrying grant_id, source_system, classification, owner_scope, payload, policy_version, and a derived idempotency_key. The classification field draws from an immutable enum of PUBLIC, CUI, HUMAN_SUBJECTS, and HAZMAT that drives storage and access scope. One IngressRecord identity produces many BoundaryLedger rows over time, related one-to-many on idempotency_key. Sponsor portal eRA Commons / Research.gov Departmental LIMS equipment / chemical feed HR export personnel certifications IngressRecord (canonical) grant_idstr · identity source_systemenum classificationenum owner_scopestr · AC-6 payloaddict policy_versionsemver idempotency_keysha256 · derived BoundaryLedger (append-only) idempotency_keyPK grant_idstr classificationstr owner_scopestr state_hashsha256 policy_versionsemver committed_atts Classification (immutable enum) PUBLIC · CUI · HUMAN_SUBJECTS · HAZMAT never downgraded once committed; drives storage + access scope map to canonical shape 1 N · keyed by idempotency_key enum source
Field Type Constraint Source rule
grant_id str Uppercased, trimmed, non-empty Institutional master index
source_system str Enum: ERA_COMMONS, RESEARCH_GOV, LIMS, HR_EXPORT Sponsor / system registry
classification str Enum: PUBLIC, CUI, HUMAN_SUBJECTS, HAZMAT NIH / NSF / OSHA / EPA
owner_scope str Must match a scope held by the calling service account NIST SP 800-53 AC-6
payload dict Validated against the schema for its classification 2 CFR 200 / NIH GPS
policy_version str Semver of the rule set evaluated Policy mapping layer
idempotency_key str SHA-256 over canonical identity + payload + policy version Audit anchoring

Two field-level rules matter for reproducibility. The idempotency_key is computed only over canonical, validated content — never over a wall-clock timestamp or unsorted dict — so the same logical record always yields the same key on replay. And classification is immutable once committed: a later payload that proposes a lower classification for an existing record (for example, demoting a HUMAN_SUBJECTS row to PUBLIC) is treated as a policy violation, not an update.

Implementation: Validating and Committing Across the Boundary

The implementation pattern is the same one used throughout this architecture: a Pydantic model normalizes and validates the payload, a deterministic key is derived from canonical content, and a SQLAlchemy upsert commits the record exactly once while routing every rejection to a quarantine queue. The boundary processor below is idempotent — repeated runs over the same batch converge to the same committed state — and it never raises out of the batch loop, so one bad record cannot halt a multi-million-dollar instrumentation grant’s sync.

The flow each payload follows across the boundary:

Boundary crossing flow: validate, scope-check, commit idempotently A payload from a federated departmental API hits a schema-validation and mutual-TLS gate. Malformed records divert to the quarantine queue. Valid records reach an owner-scope check; over-privileged records also divert to quarantine, while scope-granted records pass to an idempotent commit that writes the trusted zone and the append-only ledger. Federateddepartmental API Schema validation+ mTLS Owner scopecheck Idempotentcommit Trusted zone+ append-only ledger Quarantine queue valid granted reject scope denied

Every payload crosses a single validation boundary — non-compliant or over-privileged records divert to the quarantine queue, never the trusted zone.

python
from __future__ import annotations

import hashlib
import json
import logging
from datetime import datetime, timezone
from enum import Enum

from pydantic import BaseModel, Field, field_validator
from sqlalchemy import String, JSON, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy.dialects.postgresql import insert as pg_insert

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("security_boundary")


class Classification(str, Enum):
    PUBLIC = "PUBLIC"
    CUI = "CUI"
    HUMAN_SUBJECTS = "HUMAN_SUBJECTS"
    HAZMAT = "HAZMAT"


# Ordered from least to most sensitive; a record may never be re-committed
# at a lower rank than it already holds in the trusted zone.
_RANK: dict[Classification, int] = {
    Classification.PUBLIC: 0,
    Classification.CUI: 1,
    Classification.HUMAN_SUBJECTS: 2,
    Classification.HAZMAT: 2,
}


class IngressRecord(BaseModel):
    """Canonical shape every source payload is mapped onto before it may cross."""

    grant_id: str
    source_system: str
    classification: Classification
    owner_scope: str
    payload: dict
    policy_version: str

    @field_validator("grant_id", "owner_scope")
    @classmethod
    def _normalize(cls, value: str) -> str:
        normalized = value.strip().upper()
        if not normalized:
            raise ValueError("identity field must be non-empty")
        return normalized

    def idempotency_key(self) -> str:
        """SHA-256 over canonical identity + payload + policy version.

        Pydantic's model_dump_json with sorted keys fixes field order, so the
        same logical record always yields the same key on replay.
        """
        anchor = {
            "grant_id": self.grant_id,
            "source_system": self.source_system,
            "classification": self.classification.value,
            "payload": self.payload,
            "policy_version": self.policy_version,
        }
        canonical = json.dumps(anchor, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


class Base(DeclarativeBase):
    pass


class BoundaryLedger(Base):
    __tablename__ = "boundary_ledger"

    idempotency_key: Mapped[str] = mapped_column(String, primary_key=True)
    grant_id: Mapped[str] = mapped_column(String, index=True)
    classification: Mapped[str] = mapped_column(String)
    owner_scope: Mapped[str] = mapped_column(String)
    state_hash: Mapped[str] = mapped_column(String)
    policy_version: Mapped[str] = mapped_column(String)
    committed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
    payload: Mapped[dict] = mapped_column(JSON)


class SecurityBoundary:
    """Idempotent ingress controller for grant and lab-inventory payloads."""

    def __init__(self, session: Session, granted_scopes: set[str]) -> None:
        self.session = session
        # The scopes the calling service account actually holds. A payload
        # claiming an owner_scope outside this set is an escalation attempt.
        self.granted_scopes = granted_scopes
        self.quarantine: list[dict] = []

    def _quarantine(self, raw: dict, reason: str) -> None:
        logger.warning("Boundary rejected (%s): %s", reason, raw.get("grant_id", "UNKNOWN"))
        self.quarantine.append({"payload": raw, "reason": reason})

    def _existing_rank(self, grant_id: str) -> int | None:
        row = (
            self.session.query(BoundaryLedger)
            .filter(BoundaryLedger.grant_id == grant_id)
            .order_by(BoundaryLedger.committed_at.desc())
            .first()
        )
        return _RANK[Classification(row.classification)] if row else None

    def ingest(self, raw_payloads: list[dict]) -> dict[str, int]:
        stats = {"committed": 0, "idempotent_hit": 0, "quarantined": 0}

        for raw in raw_payloads:
            # 1. Schema boundary — malformed payloads never reach scope checks.
            try:
                record = IngressRecord.model_validate(raw)
            except Exception as exc:  # pydantic.ValidationError and friends
                self._quarantine(raw, f"schema_violation: {exc.__class__.__name__}")
                stats["quarantined"] += 1
                continue

            # 2. Least-privilege boundary — no escalation past the account's scope.
            if record.owner_scope not in self.granted_scopes:
                self._quarantine(raw, "scope_denied")
                stats["quarantined"] += 1
                continue

            # 3. Classification boundary — sensitivity may never be downgraded.
            prior_rank = self._existing_rank(record.grant_id)
            if prior_rank is not None and _RANK[record.classification] < prior_rank:
                self._quarantine(raw, "classification_downgrade")
                stats["quarantined"] += 1
                continue

            key = record.idempotency_key()

            # 4. Idempotent commit — INSERT ... ON CONFLICT DO NOTHING means a
            # re-polled payload is a no-op, not a duplicate row.
            state_hash = hashlib.sha256(
                record.model_dump_json().encode("utf-8")
            ).hexdigest()
            stmt = (
                pg_insert(BoundaryLedger)
                .values(
                    idempotency_key=key,
                    grant_id=record.grant_id,
                    classification=record.classification.value,
                    owner_scope=record.owner_scope,
                    state_hash=state_hash,
                    policy_version=record.policy_version,
                    committed_at=datetime.now(timezone.utc),
                    payload=record.payload,
                )
                .on_conflict_do_nothing(index_elements=["idempotency_key"])
            )
            result = self.session.execute(stmt)
            if result.rowcount == 0:
                stats["idempotent_hit"] += 1
                logger.info("Idempotent hit: %s", key[:12])
            else:
                stats["committed"] += 1
                logger.info("Boundary accepted: %s | %s", record.grant_id, key[:12])

        self.session.commit()
        return stats

The four checks run in a fixed order — schema, then scope, then classification, then idempotent commit — because each depends on the previous having passed. A payload that fails validation never reaches the scope check; a payload that fails the scope check never touches the trusted zone. Routing exceptions to self.quarantine instead of raising keeps the batch alive: the saturation and replay behavior of that queue is handled the same way as in the Grant Lifecycle Architecture Design, so quarantined records re-enter under fresh keys once their source is corrected.

Integration Points

The boundary sits between external systems and the trusted zone, so its contract with each adjacent system is part of the configuration, not an afterthought.

  • Sponsor portals (eRA Commons, Research.gov). Inbound award and financial records arrive over mutual TLS and are mapped to IngressRecord with source_system set accordingly. Cost fields are classified CUI; the boundary commit feeds the canonical award record owned by the lifecycle model.
  • LIMS / lab inventory. Equipment and chemical manifests arrive from departmental systems and are classified HAZMAT when they carry regulated substances, triggering the reconciliation predicate before commit. The committed records flow downstream to Equipment Calibration & Lab Inventory Tracking.
  • ERP / finance. Indirect-cost and encumbrance data is exported back out of the trusted zone only for grants the consuming account is scoped to, so the same least-privilege model governs egress as ingress.
  • Ingestion orchestration. The batch scheduling, retry, and back-pressure logic that drives the boundary lives in Automated Ingestion & Data Sync Workflows; the boundary is the validation stage that orchestration calls into.

A representative inbound payload, after a source adapter has mapped it onto the canonical shape but before it crosses the boundary:

json
{
  "grant_id": "5R01GM123456-03",
  "source_system": "ERA_COMMONS",
  "classification": "HUMAN_SUBJECTS",
  "owner_scope": "lab:neuro:write",
  "policy_version": "2026.02.1",
  "payload": {
    "pi_name": "Okafor, A.",
    "irb_protocol": "IRB-2025-0412",
    "indirect_rate": 0.555
  }
}

Verification & Audit

Verification is a hash comparison, not a trust exercise. Every committed record carries a state_hash computed over its canonical, validated content. To confirm a record in the trusted zone was not tampered with after commit, an auditor re-runs the same canonical serialization over the stored fields and compares:

python
def verify_ledger_row(row: BoundaryLedger) -> bool:
    """Recompute the state hash from stored canonical content and compare."""
    rebuilt = IngressRecord(
        grant_id=row.grant_id,
        source_system="ERA_COMMONS",  # stored alongside in production
        classification=Classification(row.classification),
        owner_scope=row.owner_scope,
        payload=row.payload,
        policy_version=row.policy_version,
    )
    expected = hashlib.sha256(rebuilt.model_dump_json().encode("utf-8")).hexdigest()
    return expected == row.state_hash

Because the hash is computed only over canonical content — never over committed_at or any mutable column — a reproducible match confirms integrity, and any divergence is itself an audit finding. The decision ledger is append-only: a correction is a new row under a new idempotency key, never an in-place edit, so the full history of what crossed the boundary and why remains intact for the records-retention period required by 2 CFR 200.

Failure Modes & Recovery

Recovery never edits the trusted zone directly; every correction flows back through ingest so it is validated, scope-checked, and logged like any other crossing.

Symptom Root cause Idempotent-safe recovery
Same record re-committed every run Idempotency key derived from a non-canonical payload (unsorted dict, wall-clock field folded in) Map the source through IngressRecord first so model_dump_json fixes field order, then backfill the ledger so the stable key is recognized on replay
Quarantine queue saturation Upstream API version drift produced systematically malformed payloads Group quarantine by reason, validate the source against the current schema, fix the source adapter; corrected records re-enter under fresh keys without duplicating committed rows
Valid record rejected after a policy update A stricter rule version applied mid-batch and the source predates it Run the policy dry-run against the ledger, correct or stage the record for the next batch window; never lower the predicate to force it through
Scope-denied on a legitimate write Service account missing a scope it should hold, or owner_scope mis-mapped at ingestion Fix the account’s granted scopes (AC-6) or the source mapping under version control and re-ingest — never widen granted_scopes ad hoc to clear a backlog

Frequently Asked Questions

Why validate at a boundary if the connection already uses mutual TLS?

Mutual TLS authenticates the connection — it proves who is calling and encrypts the transport. It says nothing about whether the payload is well-formed, whether the caller is scoped to write the grant it references, or whether a field carries human-subjects data that must be encrypted at rest. The boundary enforces those content and authorization rules; transport security is a prerequisite, not a substitute.

What goes into the idempotency key, and why fold in the policy version?

The key is a SHA-256 over the canonical award identity, the payload, and the active policy version. Folding the policy version in means re-evaluating the same record under a changed rule produces a new, distinct ledger row instead of silently reusing a stale decision, while a re-poll of unchanged data under the same rule resolves to an idempotent_hit and commits nothing.

What happens to a payload that fails validation or a scope check?

It is written to the quarantine queue with an explicit reason (schema_violation, scope_denied, classification_downgrade, or a policy error) and never reaches the trusted zone. It is not auto-corrected. A compliance officer or developer fixes the source or the account scope, and the corrected record re-enters under a new idempotency key through the same code path.

How does an auditor confirm a committed record was not altered after the fact?

They recompute the canonical serialization and SHA-256 over the record’s stored fields and compare it to the state_hash on the ledger row. Because the hash covers only canonical, validated content and never mutable columns like committed_at, a reproducible match confirms integrity and any divergence is an audit finding in its own right.