ferpa-haystack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: ferpa-haystack
3
+ Version: 0.1.0
4
+ Summary: FERPA-compliant document filter for Haystack RAG pipelines — enforces identity-scoped access control before documents reach the LLM
5
+ Project-URL: Homepage, https://github.com/ashutoshrana/ferpa-haystack
6
+ Project-URL: Documentation, https://github.com/ashutoshrana/ferpa-haystack#readme
7
+ Project-URL: Bug Tracker, https://github.com/ashutoshrana/ferpa-haystack/issues
8
+ Project-URL: Source Code, https://github.com/ashutoshrana/ferpa-haystack
9
+ Author-email: Ashutosh Rana <ai.automate101@gmail.com>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: AI-governance,FERPA,LLM,NLP,RAG,compliance,data-privacy,education,haystack,higher-education
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: haystack-ai>=2.0.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: hatch; extra == 'dev'
25
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # ferpa-haystack
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
33
+ [![Tests](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml/badge.svg)](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml)
34
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
35
+ [![Downloads](https://img.shields.io/pypi/dm/ferpa-haystack.svg)](https://pypi.org/project/ferpa-haystack/)
36
+
37
+ **FERPA-compliant document filtering for Haystack RAG pipelines.**
38
+
39
+ Enforces 34 CFR § 99 identity-scoped access control at the retrieval layer — before any document reaches the LLM context window.
40
+
41
+ ---
42
+
43
+ ## The Problem
44
+
45
+ Standard Haystack pipelines retrieve documents and pass them directly to the LLM with no enforcement of who is allowed to see what. In higher-education deployments, this creates a structural FERPA compliance gap: a student advising chatbot may return another student's academic record, financial aid details, or disciplinary history in response to a query.
46
+
47
+ This component closes that gap by adding a two-layer compliance filter between your retriever and your LLM.
48
+
49
+ ---
50
+
51
+ ## Architecture
52
+
53
+ ```
54
+ Haystack Pipeline
55
+
56
+
57
+ InMemoryEmbeddingRetriever (or any retriever)
58
+ │ documents (all retrieved)
59
+
60
+ FERPAMetadataFilter
61
+ │ Layer 1: Identity pre-filter (student_id + institution_id)
62
+ │ Layer 2: Category authorization (academic_record, financial_aid, ...)
63
+
64
+ ├── documents ──────────────► LLM (only authorized records)
65
+ └── disclosure_record ──────► Audit log (34 CFR § 99.32)
66
+ ```
67
+
68
+ **Documents without identity metadata** (course catalogues, policy handbooks) pass through both layers unchanged — shared knowledge-base content is never blocked.
69
+
70
+ ---
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install ferpa-haystack
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Quick Start
81
+
82
+ ```python
83
+ from haystack import Pipeline
84
+ from haystack.components.generators import OpenAIGenerator
85
+ from haystack.components.retrievers import InMemoryEmbeddingRetriever
86
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
87
+ from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
88
+
89
+ doc_store = InMemoryDocumentStore()
90
+
91
+ ferpa_filter = FERPAMetadataFilter(
92
+ student_id="stu_001",
93
+ institution_id="univ_abc",
94
+ authorized_categories=["academic_record", "financial_aid"],
95
+ requesting_user_id="advisor_007",
96
+ )
97
+
98
+ pipeline = Pipeline()
99
+ pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
100
+ pipeline.add_component("ferpa_filter", ferpa_filter)
101
+ pipeline.add_component("llm", OpenAIGenerator(model="gpt-4o"))
102
+
103
+ pipeline.connect("retriever.documents", "ferpa_filter.documents")
104
+ pipeline.connect("ferpa_filter.documents", "llm.documents")
105
+
106
+ result = pipeline.run({"retriever": {"query_embedding": query_emb}})
107
+
108
+ # Only stu_001's authorized records reached the LLM
109
+ authorized_docs = result["ferpa_filter"]["documents"]
110
+
111
+ # 34 CFR § 99.32 audit entry — log this to your compliance system
112
+ audit_record = result["ferpa_filter"]["disclosure_record"]
113
+ print(audit_record.to_log_entry())
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Filtering Layers
119
+
120
+ ### Layer 1 — Identity Pre-Filter
121
+
122
+ Documents are matched against `student_id` and `institution_id` metadata fields.
123
+
124
+ | Document metadata | Outcome |
125
+ |-------------------|---------|
126
+ | No `student_id` or `institution_id` | **Pass** — treated as shared content |
127
+ | `student_id` matches | **Continue to Layer 2** |
128
+ | `student_id` does not match | **Blocked** |
129
+
130
+ ### Layer 2 — Category Authorization
131
+
132
+ When `authorized_categories` is non-empty, the document's `category` field must be in the authorized set.
133
+
134
+ ```python
135
+ # Only academic records and financial aid — disciplinary records are blocked
136
+ FERPAMetadataFilter(
137
+ student_id="stu_001",
138
+ institution_id="univ_abc",
139
+ authorized_categories=["academic_record", "financial_aid"],
140
+ # "disciplinary" is blocked even if identity matches
141
+ )
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Audit Record (34 CFR § 99.32)
147
+
148
+ Every call to `run()` produces a `FERPADisclosureRecord` regardless of how many documents are authorized:
149
+
150
+ ```python
151
+ @dataclass
152
+ class FERPADisclosureRecord:
153
+ student_id: str
154
+ institution_id: str
155
+ requesting_user_id: str
156
+ disclosed_at: datetime # UTC timestamp
157
+ total_retrieved: int # documents from retriever
158
+ total_disclosed: int # documents that passed filtering
159
+ categories_disclosed: list[str] # record categories in result
160
+ pipeline_context: str # pipeline/workflow label
161
+ ```
162
+
163
+ Log it to your compliance database:
164
+
165
+ ```python
166
+ import logging
167
+ compliance_logger = logging.getLogger("ferpa.audit")
168
+ compliance_logger.info(result["ferpa_filter"]["disclosure_record"].to_log_entry())
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Configuration
174
+
175
+ ```python
176
+ FERPAMetadataFilter(
177
+ student_id="stu_001",
178
+ institution_id="univ_abc",
179
+ authorized_categories=["academic_record"], # empty = all categories allowed
180
+ requesting_user_id="advisor_007", # recorded in audit log
181
+ student_id_field="student_id", # custom meta key
182
+ institution_id_field="institution_id", # custom meta key
183
+ category_field="category", # custom meta key
184
+ pipeline_context="advising_pipeline", # audit label
185
+ raise_on_violation=False, # True = raise PermissionError
186
+ )
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Custom Field Names
192
+
193
+ If your document store uses different metadata keys:
194
+
195
+ ```python
196
+ FERPAMetadataFilter(
197
+ student_id="stu_001",
198
+ institution_id="univ_abc",
199
+ student_id_field="learner_id", # your custom key
200
+ institution_id_field="campus_code", # your custom key
201
+ category_field="record_type", # your custom key
202
+ )
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Pipeline Serialization
208
+
209
+ The component is fully serializable for YAML/JSON pipeline storage:
210
+
211
+ ```python
212
+ pipeline.to_yaml("advising_pipeline.yaml")
213
+ pipeline_restored = Pipeline.from_yaml("advising_pipeline.yaml")
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Regulatory Basis
219
+
220
+ | Regulation | Section | What this component enforces |
221
+ |-----------|---------|------------------------------|
222
+ | FERPA | 34 CFR § 99.31(a)(1) | Legitimate educational interest — only authorized roles access records |
223
+ | FERPA | 34 CFR § 99.32 | Record of disclosures — structured audit entry on every access |
224
+
225
+ ---
226
+
227
+ ## Related Projects
228
+
229
+ - **[enterprise-rag-patterns](https://github.com/ashutoshrana/enterprise-rag-patterns)** — FERPA, HIPAA, GDPR compliance patterns for RAG across 50+ regulated sectors
230
+ - **[regulated-ai-governance](https://github.com/ashutoshrana/regulated-ai-governance)** — Policy enforcement for AI agents across 25 jurisdictions
231
+
232
+ ---
233
+
234
+ ## License
235
+
236
+ Apache License 2.0 — see [LICENSE](LICENSE)
@@ -0,0 +1,10 @@
1
+ haystack_integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ haystack_integrations/components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ haystack_integrations/components/filters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ haystack_integrations/components/filters/ferpa_filter/__about__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
5
+ haystack_integrations/components/filters/ferpa_filter/__init__.py,sha256=CHis063Qne0jEUAUprYiHDyjuThBw0xGiVgdhaMoAHY,204
6
+ haystack_integrations/components/filters/ferpa_filter/ferpa_metadata_filter.py,sha256=yJO9m38cyPOMLx_ndmnvpz3J_Wm5bfdqQsjlxML_U08,10253
7
+ ferpa_haystack-0.1.0.dist-info/METADATA,sha256=2ajhsWCzmMKVtsgfE3CJ7GkMNcTQdO4QtyY-s08zphY,8253
8
+ ferpa_haystack-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ ferpa_haystack-0.1.0.dist-info/licenses/LICENSE,sha256=bNTXqHZyIInyEg4qC0U3lWDgV0XqOrafcmBK-TVMeWs,742
10
+ ferpa_haystack-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Ashutosh Rana
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
File without changes
File without changes
File without changes
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from haystack_integrations.components.filters.ferpa_filter.ferpa_metadata_filter import (
2
+ FERPADisclosureRecord,
3
+ FERPAMetadataFilter,
4
+ )
5
+
6
+ __all__ = ["FERPADisclosureRecord", "FERPAMetadataFilter"]
@@ -0,0 +1,250 @@
1
+ """
2
+ FERPAMetadataFilter — FERPA-compliant document filter for Haystack RAG pipelines.
3
+
4
+ Enforces identity-scoped access control on retriever results before they reach
5
+ the LLM context window. Complies with 34 CFR § 99.31(a)(1) (legitimate educational
6
+ interest) and § 99.32 (record of disclosures).
7
+
8
+ Two filtering layers applied in sequence:
9
+
10
+ 1. Identity pre-filter — removes documents whose student_id or institution_id
11
+ metadata does not match the authorized scope.
12
+ 2. Category authorization — removes documents whose category is not in the
13
+ authorized set (e.g., only ACADEMIC_RECORD, not DISCIPLINARY).
14
+
15
+ Documents with no identity metadata are treated as shared knowledge-base content
16
+ (course catalogues, policy handbooks) and pass through unchanged.
17
+
18
+ Usage::
19
+
20
+ from haystack import Pipeline
21
+ from haystack.components.retrievers import InMemoryEmbeddingRetriever
22
+ from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
23
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
24
+
25
+ ferpa_filter = FERPAMetadataFilter(
26
+ student_id="stu_001",
27
+ institution_id="inst_abc",
28
+ authorized_categories=["academic_record", "financial_aid"],
29
+ requesting_user_id="advisor_007",
30
+ )
31
+
32
+ pipeline = Pipeline()
33
+ pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
34
+ pipeline.add_component("ferpa_filter", ferpa_filter)
35
+ pipeline.connect("retriever.documents", "ferpa_filter.documents")
36
+
37
+ result = pipeline.run({"retriever": {"query_embedding": query_emb}})
38
+ # result["ferpa_filter"]["documents"] — only stu_001's authorized records
39
+ # result["ferpa_filter"]["disclosure_record"] — 34 CFR § 99.32 audit entry
40
+
41
+ Regulatory basis:
42
+ 34 CFR § 99.31(a)(1) — legitimate educational interest
43
+ 34 CFR § 99.32 — record of disclosures
44
+ """
45
+
46
+ import logging
47
+ from dataclasses import dataclass, field
48
+ from datetime import datetime, timezone
49
+ from typing import Any
50
+
51
+ from haystack import Document, component, default_from_dict, default_to_dict
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ _SENTINEL = object()
56
+
57
+
58
+ @dataclass
59
+ class FERPADisclosureRecord:
60
+ """
61
+ Structured audit record of a FERPA disclosure event (34 CFR § 99.32).
62
+
63
+ Attributes:
64
+ student_id: Identifier of the student whose records were accessed.
65
+ institution_id: Identifier of the institution.
66
+ requesting_user_id: User or system that requested access.
67
+ disclosed_at: UTC timestamp of the disclosure.
68
+ total_retrieved: Documents returned by the retriever before filtering.
69
+ total_disclosed: Documents that passed FERPA filtering.
70
+ categories_disclosed: Record categories included in the result.
71
+ pipeline_context: Label identifying the pipeline or workflow context.
72
+ """
73
+
74
+ student_id: str
75
+ institution_id: str
76
+ requesting_user_id: str
77
+ disclosed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
78
+ total_retrieved: int = 0
79
+ total_disclosed: int = 0
80
+ categories_disclosed: list[str] = field(default_factory=list)
81
+ pipeline_context: str = "haystack_pipeline"
82
+
83
+ def to_log_entry(self) -> str:
84
+ return (
85
+ f"[FERPA_DISCLOSURE] student_id={self.student_id!r} "
86
+ f"institution_id={self.institution_id!r} "
87
+ f"requesting_user_id={self.requesting_user_id!r} "
88
+ f"disclosed_at={self.disclosed_at.isoformat()} "
89
+ f"total_retrieved={self.total_retrieved} "
90
+ f"total_disclosed={self.total_disclosed} "
91
+ f"categories_disclosed={self.categories_disclosed!r} "
92
+ f"pipeline_context={self.pipeline_context!r}"
93
+ )
94
+
95
+
96
+ @component
97
+ class FERPAMetadataFilter:
98
+ """
99
+ Haystack component that enforces FERPA identity-scope filtering on retrieved
100
+ documents before they enter the LLM context window.
101
+
102
+ Connects to any retriever output and emits only the documents that fall within
103
+ the authorized identity scope. Always emits a FERPADisclosureRecord for
104
+ downstream compliance logging (34 CFR § 99.32).
105
+
106
+ Two enforcement layers:
107
+
108
+ 1. Identity pre-filter: student_id and institution_id in Document.meta must
109
+ match the authorized scope. Documents with neither field are shared content
110
+ and pass through unchanged.
111
+
112
+ 2. Category authorization: when authorized_categories is non-empty, the
113
+ document's category field must be in the authorized set.
114
+
115
+ Args:
116
+ student_id: Authorized student identifier.
117
+ institution_id: Authorized institution identifier.
118
+ authorized_categories: Permitted record category strings.
119
+ Empty list means all categories are allowed.
120
+ requesting_user_id: Identifier of the requesting user (for audit log).
121
+ student_id_field: Meta key for student identifier. Default: "student_id".
122
+ institution_id_field: Meta key for institution identifier. Default: "institution_id".
123
+ category_field: Meta key for record category. Default: "category".
124
+ pipeline_context: Label for the audit record. Default: "haystack_pipeline".
125
+ raise_on_violation: When True, raise PermissionError on unauthorized docs.
126
+ When False (default), silently remove and emit WARNING.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ student_id: str,
132
+ institution_id: str,
133
+ authorized_categories: list[str] | None = None,
134
+ requesting_user_id: str = "unknown",
135
+ student_id_field: str = "student_id",
136
+ institution_id_field: str = "institution_id",
137
+ category_field: str = "category",
138
+ pipeline_context: str = "haystack_pipeline",
139
+ raise_on_violation: bool = False,
140
+ ) -> None:
141
+ self.student_id = student_id
142
+ self.institution_id = institution_id
143
+ self.authorized_categories = list(authorized_categories) if authorized_categories else []
144
+ self.requesting_user_id = requesting_user_id
145
+ self.student_id_field = student_id_field
146
+ self.institution_id_field = institution_id_field
147
+ self.category_field = category_field
148
+ self.pipeline_context = pipeline_context
149
+ self.raise_on_violation = raise_on_violation
150
+
151
+ def to_dict(self) -> dict[str, Any]:
152
+ return default_to_dict(
153
+ self,
154
+ student_id=self.student_id,
155
+ institution_id=self.institution_id,
156
+ authorized_categories=self.authorized_categories,
157
+ requesting_user_id=self.requesting_user_id,
158
+ student_id_field=self.student_id_field,
159
+ institution_id_field=self.institution_id_field,
160
+ category_field=self.category_field,
161
+ pipeline_context=self.pipeline_context,
162
+ raise_on_violation=self.raise_on_violation,
163
+ )
164
+
165
+ @classmethod
166
+ def from_dict(cls, data: dict[str, Any]) -> "FERPAMetadataFilter":
167
+ return default_from_dict(cls, data)
168
+
169
+ @component.output_types(documents=list[Document], disclosure_record=FERPADisclosureRecord)
170
+ def run(self, documents: list[Document]) -> dict[str, Any]:
171
+ """
172
+ Filter documents to the authorized identity scope.
173
+
174
+ Args:
175
+ documents: Documents from an upstream retriever.
176
+
177
+ Returns:
178
+ documents: Authorized documents only.
179
+ disclosure_record: FERPADisclosureRecord for compliance logging.
180
+
181
+ Raises:
182
+ PermissionError: Only when raise_on_violation=True and unauthorized
183
+ documents were detected.
184
+ """
185
+ total_retrieved = len(documents)
186
+ authorized: list[Document] = []
187
+
188
+ for doc in documents:
189
+ if self._is_authorized(doc):
190
+ authorized.append(doc)
191
+
192
+ removed = total_retrieved - len(authorized)
193
+
194
+ if removed > 0:
195
+ if self.raise_on_violation:
196
+ raise PermissionError(
197
+ f"FERPA violation: {removed} unauthorized document(s) blocked for "
198
+ f"student={self.student_id!r}, institution={self.institution_id!r}."
199
+ )
200
+ logger.warning(
201
+ "[FERPA_FILTER] Blocked %d unauthorized document(s) student_id=%r institution_id=%r",
202
+ removed, self.student_id, self.institution_id,
203
+ )
204
+
205
+ record = FERPADisclosureRecord(
206
+ student_id=self.student_id,
207
+ institution_id=self.institution_id,
208
+ requesting_user_id=self.requesting_user_id,
209
+ total_retrieved=total_retrieved,
210
+ total_disclosed=len(authorized),
211
+ categories_disclosed=self._extract_categories(authorized),
212
+ pipeline_context=self.pipeline_context,
213
+ )
214
+ logger.info(record.to_log_entry())
215
+ return {"documents": authorized, "disclosure_record": record}
216
+
217
+ @component.output_types(documents=list[Document], disclosure_record=FERPADisclosureRecord)
218
+ async def run_async(self, documents: list[Document]) -> dict[str, Any]:
219
+ """Async variant of run — filtering is CPU-bound, runs synchronously."""
220
+ return self.run(documents)
221
+
222
+ def _is_authorized(self, doc: Document) -> bool:
223
+ meta = doc.meta or {}
224
+ doc_student_id = meta.get(self.student_id_field, _SENTINEL)
225
+ doc_institution_id = meta.get(self.institution_id_field, _SENTINEL)
226
+
227
+ # Shared content (no identity metadata) passes through
228
+ if doc_student_id is _SENTINEL and doc_institution_id is _SENTINEL:
229
+ return True
230
+
231
+ if doc_student_id != self.student_id:
232
+ return False
233
+ if doc_institution_id is not _SENTINEL and doc_institution_id != self.institution_id:
234
+ return False
235
+
236
+ if self.authorized_categories:
237
+ doc_category = meta.get(self.category_field, _SENTINEL)
238
+ if doc_category is not _SENTINEL and doc_category not in self.authorized_categories:
239
+ return False
240
+
241
+ return True
242
+
243
+ def _extract_categories(self, documents: list[Document]) -> list[str]:
244
+ categories: set[str] = set()
245
+ for doc in documents:
246
+ meta = doc.meta or {}
247
+ cat = meta.get(self.category_field)
248
+ if cat is not None:
249
+ categories.add(str(cat))
250
+ return sorted(categories)