ferpa-haystack 0.1.0__tar.gz
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.
- ferpa_haystack-0.1.0/.github/workflows/ci.yml +29 -0
- ferpa_haystack-0.1.0/.github/workflows/release.yml +25 -0
- ferpa_haystack-0.1.0/.gitignore +7 -0
- ferpa_haystack-0.1.0/CONTRIBUTING.md +96 -0
- ferpa_haystack-0.1.0/LICENSE +17 -0
- ferpa_haystack-0.1.0/PKG-INFO +236 -0
- ferpa_haystack-0.1.0/README.md +208 -0
- ferpa_haystack-0.1.0/examples/basic_usage.py +94 -0
- ferpa_haystack-0.1.0/pyproject.toml +95 -0
- ferpa_haystack-0.1.0/src/haystack_integrations/__init__.py +0 -0
- ferpa_haystack-0.1.0/src/haystack_integrations/components/__init__.py +0 -0
- ferpa_haystack-0.1.0/src/haystack_integrations/components/filters/__init__.py +0 -0
- ferpa_haystack-0.1.0/src/haystack_integrations/components/filters/ferpa_filter/__about__.py +1 -0
- ferpa_haystack-0.1.0/src/haystack_integrations/components/filters/ferpa_filter/__init__.py +6 -0
- ferpa_haystack-0.1.0/src/haystack_integrations/components/filters/ferpa_filter/ferpa_metadata_filter.py +250 -0
- ferpa_haystack-0.1.0/tests/test_ferpa_metadata_filter.py +219 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: |
|
|
24
|
+
pip install hatch
|
|
25
|
+
hatch env create
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: hatch run test
|
|
28
|
+
- name: Lint
|
|
29
|
+
run: hatch run lint
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Release to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
release:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: release
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- name: Set up Python
|
|
15
|
+
uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.11"
|
|
18
|
+
- name: Install hatch
|
|
19
|
+
run: pip install hatch
|
|
20
|
+
- name: Build package
|
|
21
|
+
run: hatch build
|
|
22
|
+
- name: Publish to PyPI
|
|
23
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
24
|
+
with:
|
|
25
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Contributing to ferpa-haystack
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing. This project is a FERPA-compliant document filter for Haystack RAG pipelines, and we welcome contributions from the Haystack and higher-education AI community.
|
|
4
|
+
|
|
5
|
+
## Ways to Contribute
|
|
6
|
+
|
|
7
|
+
- **Bug reports** — open an issue with a minimal reproduction case
|
|
8
|
+
- **Feature requests** — open an issue describing the use case and expected behavior
|
|
9
|
+
- **Pull requests** — bug fixes, new features, documentation improvements, additional test coverage
|
|
10
|
+
- **Peer review** — review open pull requests and leave feedback
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
### Prerequisites
|
|
15
|
+
|
|
16
|
+
- Python 3.10, 3.11, or 3.12
|
|
17
|
+
- [Hatch](https://hatch.pypa.io/) for environment and test management
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install hatch
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Setup
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/ashutoshrana/ferpa-haystack.git
|
|
27
|
+
cd ferpa-haystack
|
|
28
|
+
hatch env create
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Run Tests
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
hatch run test
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Run Linter
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
hatch run lint
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
All tests must pass and lint must be clean before a PR will be reviewed.
|
|
44
|
+
|
|
45
|
+
## Project Structure
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/
|
|
49
|
+
haystack_integrations/
|
|
50
|
+
components/
|
|
51
|
+
filters/
|
|
52
|
+
ferpa_filter/
|
|
53
|
+
__init__.py
|
|
54
|
+
__about__.py
|
|
55
|
+
ferpa_metadata_filter.py ← main component
|
|
56
|
+
tests/
|
|
57
|
+
test_ferpa_metadata_filter.py
|
|
58
|
+
examples/
|
|
59
|
+
basic_pipeline.py
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Submitting a Pull Request
|
|
63
|
+
|
|
64
|
+
1. Fork the repository and create a branch from `main`.
|
|
65
|
+
2. Make your changes with clear, focused commits.
|
|
66
|
+
3. Add or update tests for any changed behavior.
|
|
67
|
+
4. Ensure `hatch run test` and `hatch run lint` both pass locally.
|
|
68
|
+
5. Open a pull request against `main` with a clear description of what changed and why.
|
|
69
|
+
|
|
70
|
+
## Issue Labels
|
|
71
|
+
|
|
72
|
+
| Label | Meaning |
|
|
73
|
+
|-------|---------|
|
|
74
|
+
| `good first issue` | Beginner-friendly — no deep codebase knowledge needed |
|
|
75
|
+
| `help wanted` | Maintainer is actively seeking contributions |
|
|
76
|
+
| `bug` | Confirmed defect with documented behavior |
|
|
77
|
+
| `enhancement` | New feature or improvement to existing behavior |
|
|
78
|
+
| `documentation` | Docs-only change |
|
|
79
|
+
|
|
80
|
+
## Code Style
|
|
81
|
+
|
|
82
|
+
- Line length: 120 characters (enforced by ruff)
|
|
83
|
+
- Type hints required on all public functions
|
|
84
|
+
- No comments explaining *what* code does — only *why* when non-obvious
|
|
85
|
+
|
|
86
|
+
## Regulatory Context
|
|
87
|
+
|
|
88
|
+
This project enforces 34 CFR § 99 (FERPA). If you are adding or changing filtering logic, please reference the specific regulation section in your PR description. Incorrect filtering behavior is a compliance defect, not just a functional bug.
|
|
89
|
+
|
|
90
|
+
## Code of Conduct
|
|
91
|
+
|
|
92
|
+
Be respectful and constructive. Maintainers will close issues or PRs that are dismissive, harassing, or off-topic without warning.
|
|
93
|
+
|
|
94
|
+
## Questions
|
|
95
|
+
|
|
96
|
+
Open an issue with the `question` label or start a [GitHub Discussion](https://github.com/ashutoshrana/ferpa-haystack/discussions).
|
|
@@ -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.
|
|
@@ -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
|
+
[](https://pypi.org/project/ferpa-haystack/)
|
|
32
|
+
[](https://pypi.org/project/ferpa-haystack/)
|
|
33
|
+
[](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
[](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,208 @@
|
|
|
1
|
+
# ferpa-haystack
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ferpa-haystack/)
|
|
4
|
+
[](https://pypi.org/project/ferpa-haystack/)
|
|
5
|
+
[](https://github.com/ashutoshrana/ferpa-haystack/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://pypi.org/project/ferpa-haystack/)
|
|
8
|
+
|
|
9
|
+
**FERPA-compliant document filtering for Haystack RAG pipelines.**
|
|
10
|
+
|
|
11
|
+
Enforces 34 CFR § 99 identity-scoped access control at the retrieval layer — before any document reaches the LLM context window.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The Problem
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
This component closes that gap by adding a two-layer compliance filter between your retriever and your LLM.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Haystack Pipeline
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
InMemoryEmbeddingRetriever (or any retriever)
|
|
30
|
+
│ documents (all retrieved)
|
|
31
|
+
▼
|
|
32
|
+
FERPAMetadataFilter
|
|
33
|
+
│ Layer 1: Identity pre-filter (student_id + institution_id)
|
|
34
|
+
│ Layer 2: Category authorization (academic_record, financial_aid, ...)
|
|
35
|
+
│
|
|
36
|
+
├── documents ──────────────► LLM (only authorized records)
|
|
37
|
+
└── disclosure_record ──────► Audit log (34 CFR § 99.32)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Documents without identity metadata** (course catalogues, policy handbooks) pass through both layers unchanged — shared knowledge-base content is never blocked.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install ferpa-haystack
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from haystack import Pipeline
|
|
56
|
+
from haystack.components.generators import OpenAIGenerator
|
|
57
|
+
from haystack.components.retrievers import InMemoryEmbeddingRetriever
|
|
58
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
59
|
+
from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
|
|
60
|
+
|
|
61
|
+
doc_store = InMemoryDocumentStore()
|
|
62
|
+
|
|
63
|
+
ferpa_filter = FERPAMetadataFilter(
|
|
64
|
+
student_id="stu_001",
|
|
65
|
+
institution_id="univ_abc",
|
|
66
|
+
authorized_categories=["academic_record", "financial_aid"],
|
|
67
|
+
requesting_user_id="advisor_007",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
pipeline = Pipeline()
|
|
71
|
+
pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
|
|
72
|
+
pipeline.add_component("ferpa_filter", ferpa_filter)
|
|
73
|
+
pipeline.add_component("llm", OpenAIGenerator(model="gpt-4o"))
|
|
74
|
+
|
|
75
|
+
pipeline.connect("retriever.documents", "ferpa_filter.documents")
|
|
76
|
+
pipeline.connect("ferpa_filter.documents", "llm.documents")
|
|
77
|
+
|
|
78
|
+
result = pipeline.run({"retriever": {"query_embedding": query_emb}})
|
|
79
|
+
|
|
80
|
+
# Only stu_001's authorized records reached the LLM
|
|
81
|
+
authorized_docs = result["ferpa_filter"]["documents"]
|
|
82
|
+
|
|
83
|
+
# 34 CFR § 99.32 audit entry — log this to your compliance system
|
|
84
|
+
audit_record = result["ferpa_filter"]["disclosure_record"]
|
|
85
|
+
print(audit_record.to_log_entry())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Filtering Layers
|
|
91
|
+
|
|
92
|
+
### Layer 1 — Identity Pre-Filter
|
|
93
|
+
|
|
94
|
+
Documents are matched against `student_id` and `institution_id` metadata fields.
|
|
95
|
+
|
|
96
|
+
| Document metadata | Outcome |
|
|
97
|
+
|-------------------|---------|
|
|
98
|
+
| No `student_id` or `institution_id` | **Pass** — treated as shared content |
|
|
99
|
+
| `student_id` matches | **Continue to Layer 2** |
|
|
100
|
+
| `student_id` does not match | **Blocked** |
|
|
101
|
+
|
|
102
|
+
### Layer 2 — Category Authorization
|
|
103
|
+
|
|
104
|
+
When `authorized_categories` is non-empty, the document's `category` field must be in the authorized set.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Only academic records and financial aid — disciplinary records are blocked
|
|
108
|
+
FERPAMetadataFilter(
|
|
109
|
+
student_id="stu_001",
|
|
110
|
+
institution_id="univ_abc",
|
|
111
|
+
authorized_categories=["academic_record", "financial_aid"],
|
|
112
|
+
# "disciplinary" is blocked even if identity matches
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Audit Record (34 CFR § 99.32)
|
|
119
|
+
|
|
120
|
+
Every call to `run()` produces a `FERPADisclosureRecord` regardless of how many documents are authorized:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
@dataclass
|
|
124
|
+
class FERPADisclosureRecord:
|
|
125
|
+
student_id: str
|
|
126
|
+
institution_id: str
|
|
127
|
+
requesting_user_id: str
|
|
128
|
+
disclosed_at: datetime # UTC timestamp
|
|
129
|
+
total_retrieved: int # documents from retriever
|
|
130
|
+
total_disclosed: int # documents that passed filtering
|
|
131
|
+
categories_disclosed: list[str] # record categories in result
|
|
132
|
+
pipeline_context: str # pipeline/workflow label
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Log it to your compliance database:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import logging
|
|
139
|
+
compliance_logger = logging.getLogger("ferpa.audit")
|
|
140
|
+
compliance_logger.info(result["ferpa_filter"]["disclosure_record"].to_log_entry())
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Configuration
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
FERPAMetadataFilter(
|
|
149
|
+
student_id="stu_001",
|
|
150
|
+
institution_id="univ_abc",
|
|
151
|
+
authorized_categories=["academic_record"], # empty = all categories allowed
|
|
152
|
+
requesting_user_id="advisor_007", # recorded in audit log
|
|
153
|
+
student_id_field="student_id", # custom meta key
|
|
154
|
+
institution_id_field="institution_id", # custom meta key
|
|
155
|
+
category_field="category", # custom meta key
|
|
156
|
+
pipeline_context="advising_pipeline", # audit label
|
|
157
|
+
raise_on_violation=False, # True = raise PermissionError
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Custom Field Names
|
|
164
|
+
|
|
165
|
+
If your document store uses different metadata keys:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
FERPAMetadataFilter(
|
|
169
|
+
student_id="stu_001",
|
|
170
|
+
institution_id="univ_abc",
|
|
171
|
+
student_id_field="learner_id", # your custom key
|
|
172
|
+
institution_id_field="campus_code", # your custom key
|
|
173
|
+
category_field="record_type", # your custom key
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Pipeline Serialization
|
|
180
|
+
|
|
181
|
+
The component is fully serializable for YAML/JSON pipeline storage:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
pipeline.to_yaml("advising_pipeline.yaml")
|
|
185
|
+
pipeline_restored = Pipeline.from_yaml("advising_pipeline.yaml")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Regulatory Basis
|
|
191
|
+
|
|
192
|
+
| Regulation | Section | What this component enforces |
|
|
193
|
+
|-----------|---------|------------------------------|
|
|
194
|
+
| FERPA | 34 CFR § 99.31(a)(1) | Legitimate educational interest — only authorized roles access records |
|
|
195
|
+
| FERPA | 34 CFR § 99.32 | Record of disclosures — structured audit entry on every access |
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Related Projects
|
|
200
|
+
|
|
201
|
+
- **[enterprise-rag-patterns](https://github.com/ashutoshrana/enterprise-rag-patterns)** — FERPA, HIPAA, GDPR compliance patterns for RAG across 50+ regulated sectors
|
|
202
|
+
- **[regulated-ai-governance](https://github.com/ashutoshrana/regulated-ai-governance)** — Policy enforcement for AI agents across 25 jurisdictions
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
Apache License 2.0 — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic usage of ferpa-haystack.
|
|
3
|
+
|
|
4
|
+
Shows how to add FERPA-compliant filtering to a Haystack RAG pipeline.
|
|
5
|
+
|
|
6
|
+
Install: pip install ferpa-haystack haystack-ai
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from haystack import Document, Pipeline
|
|
10
|
+
from haystack.components.generators import OpenAIGenerator
|
|
11
|
+
from haystack.components.builders import PromptBuilder
|
|
12
|
+
from haystack.components.retrievers import InMemoryEmbeddingRetriever
|
|
13
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
14
|
+
|
|
15
|
+
from haystack_integrations.components.filters.ferpa_filter import FERPAMetadataFilter
|
|
16
|
+
|
|
17
|
+
# Build a sample document store with identity-tagged records
|
|
18
|
+
doc_store = InMemoryDocumentStore()
|
|
19
|
+
doc_store.write_documents([
|
|
20
|
+
# Student stu_001's records
|
|
21
|
+
Document(
|
|
22
|
+
content="Alice Johnson — GPA: 3.85, Major: Computer Science, Credits: 92/120",
|
|
23
|
+
meta={"student_id": "stu_001", "institution_id": "univ_abc", "category": "academic_record"},
|
|
24
|
+
),
|
|
25
|
+
Document(
|
|
26
|
+
content="Alice Johnson — Financial Aid Award 2025: $15,000 Pell Grant + $8,500 Subsidized Loan",
|
|
27
|
+
meta={"student_id": "stu_001", "institution_id": "univ_abc", "category": "financial_aid"},
|
|
28
|
+
),
|
|
29
|
+
# Another student's record — should be blocked
|
|
30
|
+
Document(
|
|
31
|
+
content="Bob Smith — GPA: 2.9, Academic Probation Notice",
|
|
32
|
+
meta={"student_id": "stu_002", "institution_id": "univ_abc", "category": "academic_record"},
|
|
33
|
+
),
|
|
34
|
+
# Shared knowledge base — no identity metadata — always passes through
|
|
35
|
+
Document(
|
|
36
|
+
content="University Graduation Requirements: 120 credits, minimum 2.0 GPA",
|
|
37
|
+
meta={},
|
|
38
|
+
),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
# FERPA filter — only Alice's academic and financial aid records pass through
|
|
42
|
+
ferpa_filter = FERPAMetadataFilter(
|
|
43
|
+
student_id="stu_001",
|
|
44
|
+
institution_id="univ_abc",
|
|
45
|
+
authorized_categories=["academic_record", "financial_aid"],
|
|
46
|
+
requesting_user_id="advisor_007",
|
|
47
|
+
pipeline_context="student_advising_chatbot",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Build pipeline — retriever → FERPA filter → LLM
|
|
51
|
+
pipeline = Pipeline()
|
|
52
|
+
pipeline.add_component("retriever", InMemoryEmbeddingRetriever(doc_store))
|
|
53
|
+
pipeline.add_component("ferpa_filter", ferpa_filter)
|
|
54
|
+
pipeline.add_component(
|
|
55
|
+
"prompt",
|
|
56
|
+
PromptBuilder(template="Answer using only the provided student records:\n{% for doc in documents %}{{ doc.content }}\n{% endfor %}\n\nQuestion: {{ question }}")
|
|
57
|
+
)
|
|
58
|
+
pipeline.add_component("llm", OpenAIGenerator(model="gpt-4o-mini"))
|
|
59
|
+
|
|
60
|
+
pipeline.connect("retriever.documents", "ferpa_filter.documents")
|
|
61
|
+
pipeline.connect("ferpa_filter.documents", "prompt.documents")
|
|
62
|
+
pipeline.connect("prompt.prompt", "llm.prompt")
|
|
63
|
+
|
|
64
|
+
# The FERPA filter ensures Bob's record never reaches the LLM
|
|
65
|
+
# result = pipeline.run({
|
|
66
|
+
# "retriever": {"query_embedding": embed("What is Alice's GPA?")},
|
|
67
|
+
# "prompt": {"question": "What is the student's GPA?"},
|
|
68
|
+
# })
|
|
69
|
+
|
|
70
|
+
# Standalone usage (no embedding needed for this demo)
|
|
71
|
+
sample_docs = [
|
|
72
|
+
Document(
|
|
73
|
+
content="Alice — GPA 3.85",
|
|
74
|
+
meta={"student_id": "stu_001", "institution_id": "univ_abc", "category": "academic_record"},
|
|
75
|
+
),
|
|
76
|
+
Document(
|
|
77
|
+
content="Bob — GPA 2.9", # different student
|
|
78
|
+
meta={"student_id": "stu_002", "institution_id": "univ_abc", "category": "academic_record"},
|
|
79
|
+
),
|
|
80
|
+
Document(
|
|
81
|
+
content="Graduation requires 120 credits", # shared content
|
|
82
|
+
meta={},
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
result = ferpa_filter.run(sample_docs)
|
|
87
|
+
print(f"Input: {len(sample_docs)} documents")
|
|
88
|
+
print(f"After FERPA filter: {len(result['documents'])} documents")
|
|
89
|
+
print(f"Audit log: {result['disclosure_record'].to_log_entry()}")
|
|
90
|
+
|
|
91
|
+
# Expected output:
|
|
92
|
+
# Input: 3 documents
|
|
93
|
+
# After FERPA filter: 2 documents (Alice's record + shared graduation info)
|
|
94
|
+
# Audit log: [FERPA_DISCLOSURE] student_id='stu_001' ... total_retrieved=3 total_disclosed=2
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ferpa-haystack"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "FERPA-compliant document filter for Haystack RAG pipelines — enforces identity-scoped access control before documents reach the LLM"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ashutosh Rana", email = "ai.automate101@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"haystack",
|
|
17
|
+
"RAG",
|
|
18
|
+
"FERPA",
|
|
19
|
+
"compliance",
|
|
20
|
+
"education",
|
|
21
|
+
"NLP",
|
|
22
|
+
"LLM",
|
|
23
|
+
"data-privacy",
|
|
24
|
+
"higher-education",
|
|
25
|
+
"AI-governance",
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 4 - Beta",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"License :: OSI Approved :: Apache Software License",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
36
|
+
]
|
|
37
|
+
dependencies = [
|
|
38
|
+
"haystack-ai>=2.0.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=7.0",
|
|
44
|
+
"pytest-asyncio>=0.21",
|
|
45
|
+
"hatch",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/ashutoshrana/ferpa-haystack"
|
|
50
|
+
Documentation = "https://github.com/ashutoshrana/ferpa-haystack#readme"
|
|
51
|
+
"Bug Tracker" = "https://github.com/ashutoshrana/ferpa-haystack/issues"
|
|
52
|
+
"Source Code" = "https://github.com/ashutoshrana/ferpa-haystack"
|
|
53
|
+
|
|
54
|
+
[tool.hatch.version]
|
|
55
|
+
path = "src/haystack_integrations/components/filters/ferpa_filter/__about__.py"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/haystack_integrations"]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.envs.default]
|
|
61
|
+
dependencies = [
|
|
62
|
+
"coverage[toml]>=6.5",
|
|
63
|
+
"pytest>=7.0",
|
|
64
|
+
"pytest-asyncio>=0.21",
|
|
65
|
+
"haystack-ai>=2.0.0",
|
|
66
|
+
"ruff>=0.4.0",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.hatch.envs.default.scripts]
|
|
70
|
+
test = "pytest {args:tests}"
|
|
71
|
+
test-cov = "coverage run -m pytest {args:tests}"
|
|
72
|
+
cov-report = ["coverage report"]
|
|
73
|
+
fmt = ["ruff format {args:.}", "ruff check --fix {args:.}"]
|
|
74
|
+
lint = ["ruff check {args:.}"]
|
|
75
|
+
|
|
76
|
+
[tool.hatch.envs.test]
|
|
77
|
+
[[tool.hatch.envs.test.matrix]]
|
|
78
|
+
python = ["3.10", "3.11", "3.12"]
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
line-length = 120
|
|
82
|
+
target-version = "py310"
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint]
|
|
85
|
+
select = ["E", "F", "W"]
|
|
86
|
+
ignore = [
|
|
87
|
+
"E501", # line too long — handled by formatter
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint.per-file-ignores]
|
|
91
|
+
"examples/*" = ["E402", "F401", "F811"]
|
|
92
|
+
"tests/*" = ["S101"]
|
|
93
|
+
|
|
94
|
+
[tool.pytest.ini_options]
|
|
95
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests for FERPAMetadataFilter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from haystack import Document
|
|
7
|
+
from haystack_integrations.components.filters.ferpa_filter import (
|
|
8
|
+
FERPADisclosureRecord,
|
|
9
|
+
FERPAMetadataFilter,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def default_filter() -> FERPAMetadataFilter:
|
|
15
|
+
return FERPAMetadataFilter(
|
|
16
|
+
student_id="stu_001",
|
|
17
|
+
institution_id="inst_abc",
|
|
18
|
+
authorized_categories=["academic_record", "financial_aid"],
|
|
19
|
+
requesting_user_id="advisor_007",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture()
|
|
24
|
+
def authorized_doc() -> Document:
|
|
25
|
+
return Document(
|
|
26
|
+
content="GPA: 3.8",
|
|
27
|
+
meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "academic_record"},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture()
|
|
32
|
+
def wrong_student_doc() -> Document:
|
|
33
|
+
return Document(
|
|
34
|
+
content="GPA: 3.5",
|
|
35
|
+
meta={"student_id": "stu_002", "institution_id": "inst_abc", "category": "academic_record"},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture()
|
|
40
|
+
def shared_doc() -> Document:
|
|
41
|
+
return Document(content="Course Catalogue 2025", meta={})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestIdentityFilter:
|
|
45
|
+
def test_authorized_doc_passes(self, default_filter, authorized_doc):
|
|
46
|
+
result = default_filter.run([authorized_doc])
|
|
47
|
+
assert len(result["documents"]) == 1
|
|
48
|
+
|
|
49
|
+
def test_wrong_student_blocked(self, default_filter, wrong_student_doc):
|
|
50
|
+
result = default_filter.run([wrong_student_doc])
|
|
51
|
+
assert len(result["documents"]) == 0
|
|
52
|
+
|
|
53
|
+
def test_shared_content_passes(self, default_filter, shared_doc):
|
|
54
|
+
result = default_filter.run([shared_doc])
|
|
55
|
+
assert len(result["documents"]) == 1
|
|
56
|
+
|
|
57
|
+
def test_mixed_batch(self, default_filter, authorized_doc, wrong_student_doc, shared_doc):
|
|
58
|
+
result = default_filter.run([authorized_doc, wrong_student_doc, shared_doc])
|
|
59
|
+
assert len(result["documents"]) == 2
|
|
60
|
+
|
|
61
|
+
def test_wrong_institution_blocked(self, default_filter):
|
|
62
|
+
doc = Document(
|
|
63
|
+
content="...",
|
|
64
|
+
meta={"student_id": "stu_001", "institution_id": "inst_xyz", "category": "academic_record"},
|
|
65
|
+
)
|
|
66
|
+
result = default_filter.run([doc])
|
|
67
|
+
assert len(result["documents"]) == 0
|
|
68
|
+
|
|
69
|
+
def test_empty_input(self, default_filter):
|
|
70
|
+
result = default_filter.run([])
|
|
71
|
+
assert result["documents"] == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestCategoryAuthorization:
|
|
75
|
+
def test_authorized_category_passes(self, default_filter):
|
|
76
|
+
doc = Document(
|
|
77
|
+
content="...",
|
|
78
|
+
meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "financial_aid"},
|
|
79
|
+
)
|
|
80
|
+
assert len(default_filter.run([doc])["documents"]) == 1
|
|
81
|
+
|
|
82
|
+
def test_unauthorized_category_blocked(self, default_filter):
|
|
83
|
+
doc = Document(
|
|
84
|
+
content="...",
|
|
85
|
+
meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "disciplinary"},
|
|
86
|
+
)
|
|
87
|
+
assert len(default_filter.run([doc])["documents"]) == 0
|
|
88
|
+
|
|
89
|
+
def test_no_category_field_passes(self, default_filter):
|
|
90
|
+
doc = Document(
|
|
91
|
+
content="...",
|
|
92
|
+
meta={"student_id": "stu_001", "institution_id": "inst_abc"},
|
|
93
|
+
)
|
|
94
|
+
assert len(default_filter.run([doc])["documents"]) == 1
|
|
95
|
+
|
|
96
|
+
def test_empty_authorized_categories_allows_all(self):
|
|
97
|
+
f = FERPAMetadataFilter(
|
|
98
|
+
student_id="stu_001",
|
|
99
|
+
institution_id="inst_abc",
|
|
100
|
+
authorized_categories=[],
|
|
101
|
+
)
|
|
102
|
+
doc = Document(
|
|
103
|
+
content="...",
|
|
104
|
+
meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "disciplinary"},
|
|
105
|
+
)
|
|
106
|
+
assert len(f.run([doc])["documents"]) == 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestDisclosureRecord:
|
|
110
|
+
def test_record_always_present(self, default_filter, authorized_doc):
|
|
111
|
+
result = default_filter.run([authorized_doc])
|
|
112
|
+
assert "disclosure_record" in result
|
|
113
|
+
assert isinstance(result["disclosure_record"], FERPADisclosureRecord)
|
|
114
|
+
|
|
115
|
+
def test_record_counts(self, default_filter, authorized_doc, wrong_student_doc):
|
|
116
|
+
result = default_filter.run([authorized_doc, wrong_student_doc])
|
|
117
|
+
record = result["disclosure_record"]
|
|
118
|
+
assert record.total_retrieved == 2
|
|
119
|
+
assert record.total_disclosed == 1
|
|
120
|
+
|
|
121
|
+
def test_record_on_empty_input(self, default_filter):
|
|
122
|
+
result = default_filter.run([])
|
|
123
|
+
record = result["disclosure_record"]
|
|
124
|
+
assert record.total_retrieved == 0
|
|
125
|
+
assert record.total_disclosed == 0
|
|
126
|
+
|
|
127
|
+
def test_categories_disclosed(self, default_filter):
|
|
128
|
+
docs = [
|
|
129
|
+
Document(content="a", meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "academic_record"}),
|
|
130
|
+
Document(content="b", meta={"student_id": "stu_001", "institution_id": "inst_abc", "category": "financial_aid"}),
|
|
131
|
+
]
|
|
132
|
+
record = default_filter.run(docs)["disclosure_record"]
|
|
133
|
+
assert set(record.categories_disclosed) == {"academic_record", "financial_aid"}
|
|
134
|
+
|
|
135
|
+
def test_log_entry_format(self, default_filter, authorized_doc):
|
|
136
|
+
record = default_filter.run([authorized_doc])["disclosure_record"]
|
|
137
|
+
log = record.to_log_entry()
|
|
138
|
+
assert "[FERPA_DISCLOSURE]" in log
|
|
139
|
+
assert "student_id=" in log
|
|
140
|
+
assert "total_disclosed=" in log
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestRaiseOnViolation:
|
|
144
|
+
def test_raises_on_unauthorized(self, wrong_student_doc):
|
|
145
|
+
f = FERPAMetadataFilter(
|
|
146
|
+
student_id="stu_001",
|
|
147
|
+
institution_id="inst_abc",
|
|
148
|
+
raise_on_violation=True,
|
|
149
|
+
)
|
|
150
|
+
with pytest.raises(PermissionError, match="FERPA violation"):
|
|
151
|
+
f.run([wrong_student_doc])
|
|
152
|
+
|
|
153
|
+
def test_no_raise_when_all_authorized(self, authorized_doc):
|
|
154
|
+
f = FERPAMetadataFilter(
|
|
155
|
+
student_id="stu_001",
|
|
156
|
+
institution_id="inst_abc",
|
|
157
|
+
raise_on_violation=True,
|
|
158
|
+
)
|
|
159
|
+
result = f.run([authorized_doc])
|
|
160
|
+
assert len(result["documents"]) == 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestSerialization:
|
|
164
|
+
def test_to_dict_round_trip(self, default_filter):
|
|
165
|
+
d = default_filter.to_dict()
|
|
166
|
+
restored = FERPAMetadataFilter.from_dict(d)
|
|
167
|
+
assert restored.student_id == default_filter.student_id
|
|
168
|
+
assert restored.institution_id == default_filter.institution_id
|
|
169
|
+
assert restored.authorized_categories == default_filter.authorized_categories
|
|
170
|
+
assert restored.requesting_user_id == default_filter.requesting_user_id
|
|
171
|
+
|
|
172
|
+
def test_from_dict_preserves_all_fields(self):
|
|
173
|
+
original = FERPAMetadataFilter(
|
|
174
|
+
student_id="s1",
|
|
175
|
+
institution_id="i1",
|
|
176
|
+
authorized_categories=["academic_record"],
|
|
177
|
+
requesting_user_id="advisor_1",
|
|
178
|
+
student_id_field="learner_id",
|
|
179
|
+
institution_id_field="campus_code",
|
|
180
|
+
category_field="record_type",
|
|
181
|
+
pipeline_context="test_pipeline",
|
|
182
|
+
raise_on_violation=True,
|
|
183
|
+
)
|
|
184
|
+
restored = FERPAMetadataFilter.from_dict(original.to_dict())
|
|
185
|
+
assert restored.student_id_field == "learner_id"
|
|
186
|
+
assert restored.institution_id_field == "campus_code"
|
|
187
|
+
assert restored.category_field == "record_type"
|
|
188
|
+
assert restored.raise_on_violation is True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestCustomFieldNames:
|
|
192
|
+
def test_custom_student_field(self):
|
|
193
|
+
f = FERPAMetadataFilter(
|
|
194
|
+
student_id="s1",
|
|
195
|
+
institution_id="i1",
|
|
196
|
+
student_id_field="learner_id",
|
|
197
|
+
institution_id_field="campus_code",
|
|
198
|
+
)
|
|
199
|
+
doc = Document(content="...", meta={"learner_id": "s1", "campus_code": "i1"})
|
|
200
|
+
assert len(f.run([doc])["documents"]) == 1
|
|
201
|
+
|
|
202
|
+
def test_custom_category_field(self):
|
|
203
|
+
f = FERPAMetadataFilter(
|
|
204
|
+
student_id="s1",
|
|
205
|
+
institution_id="i1",
|
|
206
|
+
authorized_categories=["transcript"],
|
|
207
|
+
category_field="record_type",
|
|
208
|
+
)
|
|
209
|
+
doc = Document(content="...", meta={"student_id": "s1", "institution_id": "i1", "record_type": "transcript"})
|
|
210
|
+
assert len(f.run([doc])["documents"]) == 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestAsync:
|
|
214
|
+
@pytest.mark.asyncio
|
|
215
|
+
async def test_run_async_matches_run(self, default_filter, authorized_doc, wrong_student_doc):
|
|
216
|
+
sync_result = default_filter.run([authorized_doc, wrong_student_doc])
|
|
217
|
+
async_result = await default_filter.run_async([authorized_doc, wrong_student_doc])
|
|
218
|
+
assert len(sync_result["documents"]) == len(async_result["documents"])
|
|
219
|
+
assert sync_result["disclosure_record"].total_disclosed == async_result["disclosure_record"].total_disclosed
|