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.
- ferpa_haystack-0.1.0.dist-info/METADATA +236 -0
- ferpa_haystack-0.1.0.dist-info/RECORD +10 -0
- ferpa_haystack-0.1.0.dist-info/WHEEL +4 -0
- ferpa_haystack-0.1.0.dist-info/licenses/LICENSE +17 -0
- haystack_integrations/__init__.py +0 -0
- haystack_integrations/components/__init__.py +0 -0
- haystack_integrations/components/filters/__init__.py +0 -0
- haystack_integrations/components/filters/ferpa_filter/__about__.py +1 -0
- haystack_integrations/components/filters/ferpa_filter/__init__.py +6 -0
- haystack_integrations/components/filters/ferpa_filter/ferpa_metadata_filter.py +250 -0
|
@@ -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,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,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,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)
|