Securing PII in Nonprofit Grant Databases

This guide extends the Data Security & Access Boundaries module, and it solves one narrow problem: how to keep Personally Identifiable Information (PII)…

This guide extends the Data Security & Access Boundaries module, and it solves one narrow problem: how to keep Personally Identifiable Information (PII) in a grant database from leaking across a pipeline stage it was never scoped for. Exposure rarely arrives as a single catastrophic breach — it accretes as schema drift, unmasked identifiers in aggregated exports, or silent type coercion during an ETL handoff, so every defence here is built as a deterministic, stage-isolated checkpoint rather than a perimeter firewall.

The approach maps each control to a named regulatory obligation — 2 CFR §200.303 internal controls, IRS Form 990 Schedule B contributor confidentiality, and the state charity thresholds enforced by California Form RRF-1 and New York CHAR500 — so an auditor can trace any masked field back to the rule that required it.

PII pipeline: intake gate to encrypted store A four-stage horizontal data flow. An Intake Validation Gate passes records to a PII Boundary Scanner, then to a Policy and Masking Engine, and finally across a locked encryption barrier into the Encrypted Store. Records that fail the first two stages drop down a side branch into a quarantine and dead-letter sink. PII pipeline: gate → scan → mask → encrypt Each stage is a deterministic checkpoint; only validated, masked payloads cross the encryption barrier. AES-256 1 Intake Validation Gate pii_classification Strict-type contract Reject if unencrypted 2 PII Boundary Scanner SSN / EIN / DOB regex Column-level check Plaintext leak → CRITICAL 3 Policy / Masking Engine runtime YAML rules Jurisdiction + funder Tokenize / redact 4 Encrypted Store encrypted_payload AES-256-GCM column Deterministic join keys quarantine / dead-letter Schema drift and boundary violations never halt a valid batch — they divert here with full audit context for replay or review.

When to use this approach

Reach for this stage-isolated pattern when all of the following hold:

  • You ingest heterogeneous PII from federal portals, state registries, and private foundations, where each source uses a different field shape for the same identifier (SSN, EIN, beneficiary date of birth, donor address).
  • Inputs are structured records — JSON, CSV rows, or API payloads — that can be validated against a contract before persistence. Free-text intake (scanned PDFs) must first pass through PDF Grant Application Parsing and be normalized into structured records upstream.
  • A masked or tokenized export is a hard requirement, not a nice-to-have — for example a public Form 990 derived from a database that also holds Schedule B donor names that must never appear in the public artifact.
  • You need an audit trail that conforms to Compliance Metadata Standards so retention, classification, and masking decisions are reconstructable years later.

If you only need raw field-name standardization with no confidentiality boundary, use Field Mapping & Normalization instead — that layer renames columns; it does not gate on classification.

Step-by-step implementation

Step 1 — Gate intake on a strict classification contract

The ingestion layer is a stateless validation gate. Every record must declare a pii_classification, and any record tagged above PUBLIC must carry an active encryption_status or the batch is rejected before it ever touches the relational store. Strict types (StrictStr, conint) block implicit casting of a malformed identifier into a “valid-looking” value.

python
from __future__ import annotations
import logging
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError, StrictStr, conint

logger = logging.getLogger("grant_pipeline.ingestion")

class GrantIntakePayload(BaseModel):
    grantor_id: StrictStr
    submission_timestamp: datetime
    beneficiary_count: conint(ge=0)
    pii_classification: str = Field(pattern=r"^(PUBLIC|INTERNAL|RESTRICTED|CONFIDENTIAL)$")
    encryption_status: Optional[str] = None

    def validate_boundary(self) -> bool:
        if self.pii_classification != "PUBLIC" and self.encryption_status is None:
            logger.error(
                "BOUNDARY_VIOLATION | grantor_id=%s | classification=%s | encryption_status=%s",
                self.grantor_id, self.pii_classification, self.encryption_status,
            )
            return False
        return True

def process_intake_batch(raw_records: list[dict]) -> tuple[list[GrantIntakePayload], list[dict]]:
    validated: list[GrantIntakePayload] = []
    quarantine: list[dict] = []
    for idx, record in enumerate(raw_records):
        try:
            payload = GrantIntakePayload(**record)
            if not payload.validate_boundary():
                raise ValueError("Encryption status missing for restricted classification")
            validated.append(payload)
        except ValidationError as e:
            logger.warning("SCHEMA_DRIFT | record_idx=%d | error=%s", idx, e.json())
            quarantine.append(record)
        except Exception as e:  # structured catch-all: nothing is swallowed silently
            logger.error("INGESTION_FAILURE | record_idx=%d | exception=%s", idx, repr(e))
            quarantine.append(record)
    return validated, quarantine

Parameters that matter: the pii_classification regex is the contract — extending the taxonomy means editing one pattern, not scattered if statements. quarantine is returned alongside the clean set rather than raised, so a single bad row never halts an otherwise valid batch. This gate is where jurisdictional routing rules and retention policies from the Core Architecture & Compliance Mapping registry first attach to a record.

Step 2 — Scan plaintext fields for unencrypted PII

After validation, records enter the transformation stage, where funder-specific schemas are merged into a canonical model. A row-level boundary scanner enforces column-level segregation: sensitive identifiers may persist only inside the designated encrypted_payload JSONB column. A regex match for an SSN, EIN, or date of birth anywhere outside that container triggers rejection.

python
import re
from typing import Any

PII_PATTERNS: dict[str, re.Pattern[str]] = {
    "SSN": re.compile(r"\b(?!000|666|9\d{2})\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b"),
    "EIN": re.compile(r"\b\d{2}-\d{7}\b"),
    "DOB": re.compile(r"\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b"),
}

def enforce_pii_boundary(record: dict[str, Any], encrypted_col: str = "encrypted_payload") -> bool:
    """Return False if any plaintext field outside encrypted_col contains a PII pattern."""
    for key, value in record.items():
        if key == encrypted_col or not isinstance(value, str):
            continue
        for pattern_name, regex in PII_PATTERNS.items():
            if regex.search(value):
                logger.critical(
                    "PII_EXPOSURE_DETECTED | field=%s | pattern=%s | record_id=%s",
                    key, pattern_name, record.get("grantor_id", "UNKNOWN"),
                )
                return False
    return True

Parameters that matter: encrypted_col is configurable so the same scanner protects multiple tables. The SSN pattern excludes structurally invalid area numbers (000, 666, 9xx) to cut false positives on lookalike strings. A failure logs at CRITICAL because an unmasked identifier in a plaintext column is a reportable confidentiality event under 2 CFR §200.303(e).

Step 3 — Apply masking and tokenization from a runtime policy

Masking thresholds vary by jurisdiction and funder, so they live in YAML read at runtime — never hardcoded. This is what lets a single codebase satisfy IRS Form 990 Schedule B (contributor names stripped before public aggregation), the state thresholds owned by State Charity Registration Compliance, and the per-funder rules owned by Grantor-Specific Rule Taxonomies without a redeploy.

python
import yaml
from pathlib import Path
from typing import Any, Dict

class CompliancePolicyEngine:
    def __init__(self, config_path: Path) -> None:
        self._policies: Dict[str, Dict[str, Any]] = self._load_policies(config_path)

    def _load_policies(self, path: Path) -> Dict[str, Dict[str, Any]]:
        try:
            with path.open("r") as f:
                return yaml.safe_load(f) or {}
        except Exception as e:
            logger.error("POLICY_LOAD_FAILURE | path=%s | error=%s", path, repr(e))
            raise RuntimeError("Compliance policy engine failed to initialize") from e

    def get_masking_rules(self, jurisdiction: str, funder_id: str) -> Dict[str, int]:
        return self._policies.get(jurisdiction, {}).get(funder_id, {}).get(
            "masking_rules", {"default": 0}
        )

    def apply_tokenization(self, record: Dict[str, Any], rules: Dict[str, int]) -> Dict[str, Any]:
        tokenized = record.copy()
        for field, depth in rules.items():
            if field in tokenized and isinstance(tokenized[field], str):
                tokenized[field] = f"[REDACTED_{depth}]"
        return tokenized

Parameters that matter: jurisdiction keys to a state code (for example CA for Form RRF-1, NY for CHAR500) and funder_id keys to a foundation profile, so a California submission to a private funder composes both rule sets. The engine resolves field-name aliases through the canonical model defined in IRS 990 Data Schema Mapping, so donor_name and contributor collapse to the same masking rule.

Step 4 — Persist behind encryption and retry transient failures

The store applies AES-256-GCM column encryption to encrypted_payload and deterministic hashing to join keys so reports can be assembled without exposing raw identifiers. Encryption services and policy loaders fail transiently, so persistence is wrapped in idempotent retry with exponential backoff and a dead-letter route — never a silent unmasked fallback. Endpoint-level continuity is governed by Pipeline Fallback & Retry Logic.

python
import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

class PipelineRetryError(Exception):
    pass

def retry_with_audit(
    max_retries: int = 3, backoff_factor: float = 1.5, operation_name: str = "unknown"
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            attempt = 0
            while attempt < max_retries:
                try:
                    result = func(*args, **kwargs)
                    logger.info("RETRY_SUCCESS | operation=%s | attempts=%d", operation_name, attempt + 1)
                    return result
                except Exception as e:
                    attempt += 1
                    logger.warning(
                        "RETRY_ATTEMPT | operation=%s | attempt=%d/%d | error=%s",
                        operation_name, attempt, max_retries, repr(e),
                    )
                    if attempt == max_retries:
                        logger.critical("RETRY_EXHAUSTED | operation=%s | routing_to_dead_letter", operation_name)
                        raise PipelineRetryError(f"{operation_name} failed after {max_retries} attempts") from e
                    time.sleep(backoff_factor ** attempt)
            raise PipelineRetryError("Unreachable retry state")
        return wrapper
    return decorator

@retry_with_audit(max_retries=3, operation_name="encrypt_payload")
def secure_persist_payload(payload: bytes) -> str:
    return "aes256_gcm_ciphertext_placeholder"  # real impl returns ciphertext handle

Parameters that matter: an exhausted retry raises PipelineRetryError and lands the payload in a dead-letter queue with full context, so a failed encryption call can never degrade into writing the plaintext. Every row carries pii_retention_until, data_origin_fips, and classification_level columns that drive automated archival and deletion downstream.

Verification

Confirm the boundary actually holds before trusting it in production:

  1. Round-trip a known-bad batch. Feed process_intake_batch a record tagged RESTRICTED with encryption_status=None. Assert it lands in quarantine, not validated, and that a BOUNDARY_VIOLATION line appears in the log.
  2. Probe the scanner with a live-format SSN. Call enforce_pii_boundary({"notes": "123-45-6789"}) and assert it returns False with a PII_EXPOSURE_DETECTED CRITICAL entry — then move the same string into encrypted_payload and assert it returns True.
  3. Diff a masked export against the source. Run the policy engine over a Schedule B sample and assert no contributor name survives in the public artifact (grep the output for a known donor surname; expect zero hits).
  4. Force a persistence failure. Patch secure_persist_payload to raise, and assert the payload reaches the dead-letter queue after exactly max_retries attempts with a RETRY_EXHAUSTED log — and that nothing unmasked was written.
python
def test_restricted_without_encryption_is_quarantined() -> None:
    bad = {
        "grantor_id": "G-001", "submission_timestamp": "2026-01-04T00:00:00Z",
        "beneficiary_count": 12, "pii_classification": "RESTRICTED", "encryption_status": None,
    }
    validated, quarantine = process_intake_batch([bad])
    assert validated == []
    assert len(quarantine) == 1

A clean run emits one structured audit line per stage; those lines are the evidence an auditor reconstructs data lineage from, so treat a missing log as a failed test even when the data looks correct.

Common errors & fixes

Error Cause Fix
BOUNDARY_VIOLATION on every restricted row Source system sends classification but the encryption step runs after intake Move encryption before the gate, or stamp encryption_status at the producer; the gate must see it already set
PII_EXPOSURE_DETECTED on a column that is “supposed” to be safe A funder packed an SSN into a free-text notes field Route the offending column through tokenization in Step 3 before it reaches the scanner, or add it to encrypted_payload
Masking silently does nothing jurisdiction/funder_id keys miss the YAML, so get_masking_rules returns {"default": 0} Assert the resolved rule set is non-empty before persisting; fail closed when a policy is absent
RETRY_EXHAUSTED floods the dead-letter queue Encryption service is down, not the data — backoff is masking an outage Add a circuit breaker around secure_persist_payload; alert on dead-letter depth, do not auto-replay
SSN regex misses 123456789 (no dashes) PII_PATTERNS only matches the hyphenated format Normalize separators before scanning, or add an unformatted nine-digit pattern with stricter context to limit false positives

For external baselines, align column-level controls with NIST SP 800-53 Rev. 5, strict type contracts with the Pydantic documentation, and contributor-confidentiality masking with the IRS Instructions for Form 990 Schedule B rules.