Automating FERPA Compliance in Student Patron Records
This page answers one narrow operational question: why do FERPA-protected education-record fields — enrollment status, advisor, GPA, disciplinary flags — keep surfacing in patron exports after a Student Information System (SIS) term sync, and how do you enforce automated, deterministic redaction so they can never reach a downstream feed? It sits under PII Masking in Patron Data Exports, which covers the export-masking contract end to end, and within the broader Patron Validation & Privacy Data Routing architecture. If you run an academic or K-12 library whose borrower records are provisioned from a campus SIS, the leak below is one you will eventually trigger on a term-rollover night.
Problem Framing
The symptom is not a crash. The nightly SIS-to-ILS patron sync reports success, the borrower count is correct, and no exception fires. Weeks later a reporting extract — a consortium usage feed, a collection-development spreadsheet, a discovery-vendor log — is found to carry a column that should never have left the SIS: a student’s enrollment_status, an advisor_name, or worse, a disciplinary_flag. Under FERPA these are education records, and the library was never entitled to redistribute them.
The leak is visible if you diff what the SIS sent against what the patron index stored. A quick audit over a single term batch makes it obvious:
$ psql -c "SELECT jsonb_object_keys(raw_sis_payload) FROM patron_sync_audit \
WHERE batch_id = '2026-fall-rollover' LIMIT 1 \\gx"
student_id
last_name
email
enrollment_status <-- education record, should not be here
advisor_name <-- education record, should not be here
gpa <-- education record, should not be here
disciplinary_flag <-- education record, should not be here
branch_code
patron_type
Every one of those non-library fields was carried verbatim into the patron row because the sync mapper treated the SIS payload as an open dictionary and copied whatever keys arrived. The moment any of those keys reaches the masking stage of an export, it is already too late — the field is inside the trusted zone and one misconfigured extract away from disclosure.
Root Cause
The failure is a default-open field mapping. SIS exports are wide by design: a single Ellucian Banner, PowerSchool, or Workday Student feed emits the student’s full record because it does not know which consumer is reading it. The library patron sync typically consumes that feed with a permissive mapper — patron.update(**sis_record) or a SELECT *-style projection — that maps every inbound key to a patron attribute unless a field is explicitly excluded.
Deny-listing is the root cause. A denylist enumerates the fields you know are dangerous (gpa, disciplinary_flag) and drops them, but it silently admits any field the SIS adds later. When the registrar’s office enables a new immigration_status or financial_hold attribute mid-year, the denylist has no rule for it, so it flows straight through. Compounding this, FERPA draws a line the mapper does not encode: some fields are directory information (name, enrollment dates) that may be disclosed unless the student has filed a directory-information opt-out, while others are education records that may never be disclosed without consent. A mapper that keeps or drops by field name alone cannot represent “keep this field only when the opt-out flag is false.”
The fix is to invert the default. Every field must be classified before it is stored, exactly the disposition-per-field discipline used for masked exports in the parent PII Masking in Patron Data Exports contract — and any field with no classification must be rejected, not admitted.
Solution
Replace the permissive mapper with a strict allowlist gate. Fields are declared with an explicit disposition — KEEP for library-operational attributes, DIRECTORY for FERPA directory information subject to the opt-out flag, and everything else dropped. Any inbound key that is not declared is routed to a schema validation quarantine queue rather than stored, so a new SIS field fails closed and surfaces for human classification instead of leaking.
from __future__ import annotations
import enum
import hashlib
import hmac
import logging
from dataclasses import dataclass
logger = logging.getLogger("ferpa.gate")
class Disposition(enum.Enum):
KEEP = "keep" # library-operational, always retained
DIRECTORY = "directory" # FERPA directory info; retained unless opted out
TOKENIZE = "tokenize" # identity join key; stored only as HMAC surrogate
# Allowlist: any SIS field NOT listed here is dropped and quarantined.
FIELD_POLICY: dict[str, Disposition] = {
"student_id": Disposition.TOKENIZE,
"last_name": Disposition.DIRECTORY,
"first_name": Disposition.DIRECTORY,
"email": Disposition.DIRECTORY,
"enrollment_dates": Disposition.DIRECTORY,
"branch_code": Disposition.KEEP,
"patron_type": Disposition.KEEP,
"expiry_date": Disposition.KEEP,
}
@dataclass(frozen=True)
class GateResult:
patron: dict[str, str]
quarantined: dict[str, str]
def tokenize(value: str, *, secret: bytes) -> str:
"""Domain-separated HMAC-SHA-256 surrogate for a join key."""
return hmac.new(secret, f"patron:{value}".encode(), hashlib.sha256).hexdigest()
def apply_ferpa_gate(
sis_record: dict[str, str],
*,
secret: bytes,
directory_opt_out: bool,
batch_id: str,
) -> GateResult:
"""Classify every inbound SIS field before it can reach the patron index.
Unknown fields are quarantined (fail-closed), education records are dropped,
and directory information is suppressed when the student has opted out.
"""
patron: dict[str, str] = {}
quarantined: dict[str, str] = {}
for key, value in sis_record.items():
policy = FIELD_POLICY.get(key)
if policy is None:
quarantined[key] = value
logger.warning(
"unclassified_sis_field",
extra={"field": key, "batch_id": batch_id, "action": "quarantine"},
)
continue
if policy is Disposition.DIRECTORY and directory_opt_out:
logger.info(
"directory_suppressed",
extra={"field": key, "batch_id": batch_id, "action": "drop"},
)
continue
if policy is Disposition.TOKENIZE:
patron[key] = tokenize(value, secret=secret)
else:
patron[key] = value
return GateResult(patron=patron, quarantined=quarantined)
The behavioural change is the inversion of the default. Before, advisor_name and disciplinary_flag reached the patron row because nothing forbade them. After, they never appear in FIELD_POLICY, so they are dropped and their presence is logged as an unclassified field for review. When the registrar adds immigration_status next term, it too fails closed. The join key survives only as an HMAC-SHA-256 surrogate, matching the tokenization scheme the export-masking contract uses so that patron rows still join to circulation events without carrying a raw student_id.
Compliance or Privacy Impact
Automating the gate changes the FERPA posture in three concrete ways, and each has a downstream effect worth tracking.
- Directory-information opt-out is now enforced in code, not policy. The
directory_opt_outflag must come from the SIS as an authoritative per-student value. If the SIS cannot supply it, treat the flag asTrue(suppress) until it can — disclosing directory information for a student who opted out is itself a FERPA violation, so the safe default is suppression. - The audit trail becomes complete. Because every field produces either a
KEEP, adirectory_suppressed, or anunclassified_sis_fieldlog line, the drop decisions are provable. This is what lets you answer an auditor’s “how do you know GPA never reached the export?” with a log query rather than an assurance. Feed these events into the same immutable audit sink the identity pipeline defines at the routing boundary. - Retention interacts with masking. Dropping a field at ingest is not the same as purging it — the raw SIS payload may still sit in
patron_sync_audit. Align the retention window on that audit table with the schedule described in Data Retention Policies for Public Libraries, and never let the raw payload outlive the operational need. If you later aggregate these records for reporting, apply the noise controls from Implementing Differential Privacy for Patron Analytics so that even the allowlisted directory fields cannot be re-identified in a published table.
The optimization does not widen the PII surface — it narrows it — but it shifts risk onto the correctness of FIELD_POLICY. A field mistakenly declared KEEP is a leak with the gate’s full authority behind it, so changes to the policy map deserve code review and the verification below.
Verification
Confirm the gate is fail-closed with a test that asserts every known education-record field is absent from the output and that an unseen field lands in quarantine rather than in the patron row.
import pytest
SECRET = b"unit-test-secret-not-for-prod"
def test_education_records_never_reach_patron() -> None:
sis = {
"student_id": "S1234567",
"last_name": "Okafor",
"gpa": "3.8",
"advisor_name": "Dr. Reyes",
"disciplinary_flag": "true",
"branch_code": "MAIN",
"immigration_status": "F-1", # new field the SIS added this term
}
result = apply_ferpa_gate(
sis, secret=SECRET, directory_opt_out=False, batch_id="test"
)
# No education record survives the gate.
for forbidden in ("gpa", "advisor_name", "disciplinary_flag", "immigration_status"):
assert forbidden not in result.patron
# Unknown fields fail closed into quarantine, not the patron index.
assert "immigration_status" in result.quarantined
# The join key is present but only as an opaque surrogate.
assert result.patron["student_id"] != "S1234567"
assert len(result.patron["student_id"]) == 64 # sha256 hexdigest
def test_directory_opt_out_suppresses_name() -> None:
sis = {"student_id": "S1", "last_name": "Okafor", "branch_code": "MAIN"}
result = apply_ferpa_gate(
sis, secret=SECRET, directory_opt_out=True, batch_id="test"
)
assert "last_name" not in result.patron # suppressed on opt-out
assert "branch_code" in result.patron # operational field unaffected
For a running pipeline, add a continuous invariant on the patron index itself: periodically snapshot the column set of the stored patron table and assert it is a subset of the allowlisted keys. If a snapshot ever shows an unlisted column, halt the sync and quarantine the batch — the same fail-closed reflex the gate applies per field, applied to the table as a whole. Track unclassified_sis_field counts over time; a nonzero rate after a term rollover is your early signal that the SIS schema drifted and FIELD_POLICY needs a reviewed update.
Related
- PII Masking in Patron Data Exports — the parent guide to the export-masking contract and field-classification table this gate reuses.
- Implementing Differential Privacy for Patron Analytics — protecting allowlisted directory fields in aggregate reporting.
- Data Retention Policies for Public Libraries — how long the raw SIS audit payload may lawfully persist.
- Schema Validation for Ingested Records — the quarantine-queue pattern that catches unclassified fields.