needtoknow 1.0.0rc1__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.
Files changed (35) hide show
  1. needtoknow-1.0.0rc1/.gitignore +13 -0
  2. needtoknow-1.0.0rc1/CHANGELOG.md +87 -0
  3. needtoknow-1.0.0rc1/LICENSE +21 -0
  4. needtoknow-1.0.0rc1/PKG-INFO +298 -0
  5. needtoknow-1.0.0rc1/README.md +252 -0
  6. needtoknow-1.0.0rc1/needtoknow/__init__.py +41 -0
  7. needtoknow-1.0.0rc1/needtoknow/authorizers/__init__.py +1 -0
  8. needtoknow-1.0.0rc1/needtoknow/authorizers/opa.py +112 -0
  9. needtoknow-1.0.0rc1/needtoknow/authorizers/openfga.py +164 -0
  10. needtoknow-1.0.0rc1/needtoknow/authz.py +104 -0
  11. needtoknow-1.0.0rc1/needtoknow/backends/__init__.py +5 -0
  12. needtoknow-1.0.0rc1/needtoknow/backends/chroma.py +156 -0
  13. needtoknow-1.0.0rc1/needtoknow/backends/memory.py +60 -0
  14. needtoknow-1.0.0rc1/needtoknow/backends/pgvector.py +189 -0
  15. needtoknow-1.0.0rc1/needtoknow/caching.py +156 -0
  16. needtoknow-1.0.0rc1/needtoknow/events.py +77 -0
  17. needtoknow-1.0.0rc1/needtoknow/filters.py +128 -0
  18. needtoknow-1.0.0rc1/needtoknow/langchain.py +100 -0
  19. needtoknow-1.0.0rc1/needtoknow/llamaindex.py +96 -0
  20. needtoknow-1.0.0rc1/needtoknow/otel.py +55 -0
  21. needtoknow-1.0.0rc1/needtoknow/py.typed +0 -0
  22. needtoknow-1.0.0rc1/needtoknow/retriever.py +327 -0
  23. needtoknow-1.0.0rc1/needtoknow/types.py +45 -0
  24. needtoknow-1.0.0rc1/pyproject.toml +70 -0
  25. needtoknow-1.0.0rc1/tests/helpers.py +116 -0
  26. needtoknow-1.0.0rc1/tests/test_backends.py +161 -0
  27. needtoknow-1.0.0rc1/tests/test_caching_events.py +189 -0
  28. needtoknow-1.0.0rc1/tests/test_chroma_probe.py +44 -0
  29. needtoknow-1.0.0rc1/tests/test_core.py +202 -0
  30. needtoknow-1.0.0rc1/tests/test_filters.py +63 -0
  31. needtoknow-1.0.0rc1/tests/test_frameworks.py +118 -0
  32. needtoknow-1.0.0rc1/tests/test_opa.py +156 -0
  33. needtoknow-1.0.0rc1/tests/test_openfga.py +158 -0
  34. needtoknow-1.0.0rc1/tests/test_properties.py +55 -0
  35. needtoknow-1.0.0rc1/tests/test_translators.py +89 -0
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ .env
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .coverage
9
+ htmlcov/
10
+ dist/
11
+ build/
12
+ docs/_build/
13
+ *.local.md
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/). From 1.0 on, the public API is stable.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.0rc1] - 2026-10-16
10
+
11
+ The first release candidate for 1.0. The public API in the
12
+ [API reference](https://muzakkirhussain011.github.io/needtoknow/api.html) is what 1.0.0 will ship:
13
+ changes before then are fixes, unless testing the release candidate shows that a breaking change is
14
+ needed, in which case it will be announced in a second release candidate. Install it with
15
+ `pip install --pre needtoknow`.
16
+
17
+ ### Added
18
+
19
+ - `PermissionedRetriever` with `prefilter`, `postfilter` and `auto` strategies, sync
20
+ (`retrieve`) and async (`aretrieve`) entry points sharing one implementation.
21
+ - Failing closed: any authorizer error, wrong number of decisions or non-boolean decision raises
22
+ `AuthorizationError` and returns nothing.
23
+ - Re-check of pre-filtered results, with `RetrievalResult.violations` counting hits that a
24
+ backend returned despite the filter.
25
+ - A backend-neutral filter language (`In`, `And`, `Or`) with reference semantics in `matches`;
26
+ empty `And`/`Or` are rejected because an empty conjunction would mean "allow everything".
27
+ - `MetadataACLAuthorizer` (permissions stored on each chunk) and `FunctionAuthorizer` (your own
28
+ logic).
29
+ - `InMemoryBackend` with deterministic keyword scoring, for tests and small corpora.
30
+ - Property-based tests (Hypothesis) for the security guarantees on Python 3.10 to 3.13.
31
+ - Contributor documentation: CONTRIBUTING, GOVERNANCE, CODE_OF_CONDUCT, SECURITY.
32
+ - `needtoknow.backends.pgvector`: PostgreSQL + pgvector backend with the permission filter in the
33
+ query as JSON containment tests (GIN-indexable), iterative HNSW scans, `create_schema`, and a
34
+ native async search when given an async pool. Extra: `needtoknow[pgvector]`.
35
+ - `needtoknow.backends.chroma`: Chroma backend translating filters into `where` clauses. It never
36
+ stores Chroma array metadata: in chromadb 1.5.x (local mode) a deleted collection's array
37
+ metadata surfaces on new records in other collections and is used by `where` filters
38
+ (chroma-core/chroma#7594). List values become
39
+ one boolean field each, and search results are rebuilt from an exact JSON copy of the metadata,
40
+ so the re-check always sees the true permissions. Extra: `needtoknow[chroma]`.
41
+ - `needtoknow.authorizers.opa`: Open Policy Agent authorizer using only the standard library, with
42
+ per-chunk decisions and optional policy-provided filters; every failure fails closed. Example
43
+ policy in `examples/opa`.
44
+ - `filter_to_dict` / `filter_from_dict` for a JSON form of filters.
45
+ - `needtoknow.authorizers.openfga`: OpenFGA authorizer over the HTTP API (no extra dependency):
46
+ batched BatchCheck, ListObjects pre-filtering that falls back to post-filtering when the list may
47
+ be truncated, and the subject's groups sent as contextual tuples. Tested against a real OpenFGA
48
+ for random relationship graphs.
49
+ - `needtoknow.langchain`: `NeedToKnowRetriever` (a LangChain retriever; refuses to run without a
50
+ subject) and `LangChainVectorStoreBackend` (any LangChain vector store, post-filtered).
51
+ Extra: `needtoknow[langchain]`.
52
+ - `needtoknow.llamaindex`: `NeedToKnowRetriever`, `PermissionNodePostprocessor` and
53
+ `LlamaIndexBackend`. Extra: `needtoknow[llamaindex]`.
54
+ - Runnable examples for LangChain and LlamaIndex, executed in CI.
55
+ - `CachingAuthorizer`: caches pre-filters and per-chunk decisions (keyed by chunk id and
56
+ metadata) per subject for `ttl` seconds, with that as the documented revocation bound; errors and
57
+ malformed answers are never cached; `invalidate()`; sync and async.
58
+ - Retrieval events: every call, including failed ones, produces a `RetrievalEvent` for
59
+ `observers` (subject id, strategy, returned ids, denied and violation counts, duration, error
60
+ type, optional HMAC of the query). Never query or chunk text. `JsonLinesAudit` writes them as
61
+ JSON lines; observer failures are logged and never affect retrieval.
62
+ - `needtoknow.otel.OpenTelemetryObserver`: one span per retrieval with counts and outcomes, the
63
+ subject id only on request. Extra: `needtoknow[otel]`.
64
+ - Documentation site (Sphinx, MyST, Furo) in `docs/`: getting started, concepts, a security model,
65
+ operations, one page per integration, extending needtoknow, benchmarks and an API reference.
66
+ CI builds it with warnings as errors, and every push to `main` publishes it to
67
+ <https://muzakkirhussain011.github.io/needtoknow/>. Extra: `needtoknow[docs]`.
68
+ - Benchmark harness (`benchmarks/`): recall@k against exact ground truth, leaks, completeness and
69
+ latency for exact search, Chroma and pgvector across permission selectivity and layouts, with a
70
+ Kaggle notebook for real embeddings. CI runs a small version against real Chroma and PostgreSQL
71
+ and fails on any leak.
72
+ - Conformance tests: every backend must return exactly the documents the reference semantics
73
+ select, for random metadata and nested filters, and never leak through the retriever. CI runs
74
+ them against real PostgreSQL, Chroma and OPA.
75
+
76
+ ### Changed
77
+
78
+ - `RetrievalResult.complete` is documented precisely: it is `False` when needtoknow knows results
79
+ may be missing, and it trusts the backend's ranking, so it cannot see results an approximate index
80
+ failed to return. The benchmarks measure how often that happens.
81
+ - Filter values must be strings or integers, and metadata values of other types (booleans,
82
+ floats) never match. This keeps every backend's answer identical to the reference.
83
+ - The project is now `needtoknow`, a permission-aware retrieval library (ADR 0005). The earlier
84
+ Rampart gateway lives on as an example deployment in `examples/gateway`.
85
+
86
+ [Unreleased]: https://github.com/muzakkirhussain011/needtoknow/compare/v1.0.0rc1...HEAD
87
+ [1.0.0rc1]: https://github.com/muzakkirhussain011/needtoknow/releases/tag/v1.0.0rc1
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Syed Muzakkir Hussain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.5
2
+ Name: needtoknow
3
+ Version: 1.0.0rc1
4
+ Summary: Permission-aware retrieval for RAG: every user gets only the chunks they are allowed to read.
5
+ Project-URL: Homepage, https://github.com/muzakkirhussain011/needtoknow
6
+ Project-URL: Repository, https://github.com/muzakkirhussain011/needtoknow
7
+ Project-URL: Issues, https://github.com/muzakkirhussain011/needtoknow/issues
8
+ Project-URL: Changelog, https://github.com/muzakkirhussain011/needtoknow/blob/main/CHANGELOG.md
9
+ Project-URL: Documentation, https://muzakkirhussain011.github.io/needtoknow/
10
+ Author: Syed Muzakkir Hussain
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: access-control,authorization,langchain,llamaindex,llm,openfga,rag,retrieval,security,vector-search
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: Security
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Provides-Extra: chroma
28
+ Requires-Dist: chromadb>=1.5; extra == 'chroma'
29
+ Provides-Extra: dev
30
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
31
+ Requires-Dist: pytest>=8.2; extra == 'dev'
32
+ Requires-Dist: ruff==0.17.1; extra == 'dev'
33
+ Provides-Extra: docs
34
+ Requires-Dist: furo>=2025.12.19; extra == 'docs'
35
+ Requires-Dist: myst-parser>=5.1; extra == 'docs'
36
+ Requires-Dist: sphinx>=9.1; extra == 'docs'
37
+ Provides-Extra: langchain
38
+ Requires-Dist: langchain-core>=1.0; extra == 'langchain'
39
+ Provides-Extra: llamaindex
40
+ Requires-Dist: llama-index-core>=0.12; extra == 'llamaindex'
41
+ Provides-Extra: otel
42
+ Requires-Dist: opentelemetry-api>=1.30; extra == 'otel'
43
+ Provides-Extra: pgvector
44
+ Requires-Dist: psycopg[binary,pool]>=3.2; extra == 'pgvector'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # needtoknow
48
+
49
+ [![CI](https://github.com/muzakkirhussain011/needtoknow/actions/workflows/ci.yml/badge.svg)](https://github.com/muzakkirhussain011/needtoknow/actions/workflows/ci.yml)
50
+ [![Docs](https://github.com/muzakkirhussain011/needtoknow/actions/workflows/docs.yml/badge.svg)](https://muzakkirhussain011.github.io/needtoknow/)
51
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/muzakkirhussain011/needtoknow/blob/main/LICENSE)
52
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
53
+ ![Dependencies: none](https://img.shields.io/badge/core%20dependencies-none-brightgreen.svg)
54
+
55
+ **Permission-aware retrieval for RAG.** Every user gets only the chunks they are allowed to read,
56
+ whatever vector store, permission system or framework you use.
57
+
58
+ ```python
59
+ from needtoknow import PermissionedRetriever, MetadataACLAuthorizer, Subject
60
+
61
+ retriever = PermissionedRetriever(backend, MetadataACLAuthorizer())
62
+ result = retriever.retrieve("What are this year's salary bands?", Subject("bob", groups=["engineering"]), k=5)
63
+ # result.hits holds only chunks Bob may read. HR documents never reach the prompt.
64
+ ```
65
+
66
+ **[Documentation](https://muzakkirhussain011.github.io/needtoknow/)** ·
67
+ [Security model](https://muzakkirhussain011.github.io/needtoknow/security.html) ·
68
+ [Benchmarks](https://muzakkirhussain011.github.io/needtoknow/benchmarks.html) ·
69
+ [Changelog](https://github.com/muzakkirhussain011/needtoknow/blob/main/CHANGELOG.md)
70
+
71
+ ## Why
72
+
73
+ A RAG assistant over company documents is only as safe as its retrieval step. If the vector search
74
+ ignores who is asking, an engineer can ask the assistant about salaries and get HR documents
75
+ summarised back. Every team building internal RAG has to solve this, and most solve it by hand,
76
+ differently each time.
77
+
78
+ `needtoknow` is that layer as a small, tested library:
79
+
80
+ - **Works with what you have.** Wrap any search backend and any permission system. The core has
81
+ no dependencies.
82
+ - **Filters in the right place.** When the permission system can describe "everything Bob may
83
+ read" as a filter, the filter goes *inside* the vector search, so results are the true top-k of
84
+ what Bob may read. When it cannot, candidates are checked after the search and the search widens
85
+ until enough permitted results are found.
86
+ - **Fails closed.** If the permission service errors, times out or returns malformed answers, the
87
+ user gets an error, never unfiltered results.
88
+ - **Defence in depth.** Pre-filtered results are checked again, so a backend bug that ignores the
89
+ filter cannot leak. Each result reports how many such violations it caught.
90
+
91
+ ## Install
92
+
93
+ ```bash
94
+ pip install needtoknow
95
+ ```
96
+
97
+ Python 3.10 or newer. Adapters for external systems install as optional extras.
98
+
99
+ ## Quickstart
100
+
101
+ ```python
102
+ from needtoknow import Document, InMemoryBackend, MetadataACLAuthorizer, PermissionedRetriever, Subject
103
+
104
+ backend = InMemoryBackend(
105
+ [
106
+ Document("handbook", "Annual leave is 25 days.", {"allowed_groups": ["*"]}),
107
+ Document("salaries", "Salary bands for 2026 ...", {"allowed_groups": ["hr"]}),
108
+ Document("runbook", "Roll back a failed deploy ...", {"allowed_groups": ["engineering"]}),
109
+ ]
110
+ )
111
+ retriever = PermissionedRetriever(backend, MetadataACLAuthorizer())
112
+
113
+ result = retriever.retrieve("leave salary bands", Subject("bob", groups=["engineering"]), k=3)
114
+ print([hit.id for hit in result.hits]) # ['handbook']
115
+ print(result.strategy, result.complete) # prefilter True
116
+ ```
117
+
118
+ `aretrieve` is the async equivalent, with the same arguments and guarantees.
119
+
120
+ ## How it works
121
+
122
+ | Strategy | When | Cost | Result |
123
+ |---|---|---|---|
124
+ | `prefilter` | The authorizer can return a filter and the backend supports filters | One search | The exact top-k of what the subject may read |
125
+ | `postfilter` | Any authorizer | Searches `k × overfetch` candidates, widening up to `max_candidates` | Exact when enough permitted candidates exist; `complete=False` otherwise |
126
+ | `auto` (default) | Picks `prefilter` when both sides support it | | |
127
+
128
+ Every call returns a `RetrievalResult` with the hits plus `strategy`, `examined`, `denied`,
129
+ `violations` (pre-filtered hits the re-check rejected, which should always be 0) and `complete`.
130
+
131
+ ## Integrations
132
+
133
+ Each integration is an optional extra, so the core stays dependency-free.
134
+
135
+ **PostgreSQL + pgvector** (`pip install "needtoknow[pgvector]"`, pgvector 0.8+). The permission
136
+ filter runs inside the SQL query as JSON containment tests, served by a GIN index, and iterative
137
+ index scans keep HNSW returning k rows under selective filters.
138
+
139
+ ```python
140
+ from psycopg_pool import ConnectionPool
141
+
142
+ from needtoknow.backends.pgvector import PgVectorBackend, create_schema
143
+
144
+ pool = ConnectionPool("postgresql://localhost/app")
145
+ create_schema(pool, dim=768)
146
+ backend = PgVectorBackend(pool, embed=my_embedding_model) # embed: str -> list[float]
147
+ backend.add(documents)
148
+ ```
149
+
150
+ **Chroma** (`pip install "needtoknow[chroma]"`). The filter becomes Chroma's `where` clause. Each
151
+ value in a permission list is stored as its own field rather than as a Chroma array, and an exact
152
+ copy of the metadata is kept for the re-check: in chromadb 1.5.x, deleting a collection leaves its
153
+ array metadata behind for new records in other collections to pick up
154
+ ([chroma-core/chroma#7594](https://github.com/chroma-core/chroma/issues/7594)), which for
155
+ permissions would be a leak.
156
+
157
+ ```python
158
+ import chromadb
159
+
160
+ from needtoknow.backends.chroma import ChromaBackend
161
+
162
+ collection = chromadb.PersistentClient(path="./chroma").get_or_create_collection("docs")
163
+ backend = ChromaBackend(collection) # uses the collection's embedding function
164
+ ```
165
+
166
+ **Open Policy Agent** (no extra dependency). Your Rego policy returns one decision per chunk, and
167
+ can optionally return a filter so the vector store does the filtering. A complete policy is in
168
+ [`examples/opa/needtoknow.rego`](https://github.com/muzakkirhussain011/needtoknow/blob/main/examples/opa/needtoknow.rego).
169
+
170
+ ```python
171
+ from needtoknow.authorizers.opa import OPAAuthorizer
172
+
173
+ authorizer = OPAAuthorizer("http://localhost:8181", filter_path="needtoknow/filter")
174
+ ```
175
+
176
+ **OpenFGA** (no extra dependency). Relationship-based access control in the Google Zanzibar style.
177
+ Checks run in batches; for pre-filtering, ListObjects returns every document the user may view,
178
+ and if that list may have been truncated the retriever post-filters instead. The subject's groups
179
+ can be sent as contextual tuples, so memberships from your identity provider need no syncing.
180
+
181
+ ```python
182
+ from needtoknow.authorizers.openfga import OpenFGAAuthorizer
183
+
184
+ authorizer = OpenFGAAuthorizer("http://localhost:8080", store_id, resource_field="doc_id")
185
+ ```
186
+
187
+ **LangChain** (`pip install "needtoknow[langchain]"`). `NeedToKnowRetriever` is a LangChain
188
+ retriever, so it works in chains, agents and LangGraph tools; `LangChainVectorStoreBackend` turns
189
+ any LangChain vector store into a needtoknow backend (post-filtered).
190
+
191
+ ```python
192
+ from needtoknow.langchain import NeedToKnowRetriever
193
+
194
+ retriever = NeedToKnowRetriever(retriever=PermissionedRetriever(backend, authorizer))
195
+ docs = retriever.invoke("What are this year's salary bands?", subject=Subject("bob", ["sales"]))
196
+ ```
197
+
198
+ **LlamaIndex** (`pip install "needtoknow[llamaindex]"`). `NeedToKnowRetriever` for query and chat
199
+ engines, `PermissionNodePostprocessor` for existing pipelines, and `LlamaIndexBackend` to wrap an
200
+ existing index. Runnable examples for both frameworks are in [`examples/`](https://github.com/muzakkirhussain011/needtoknow/tree/main/examples).
201
+
202
+ Filter values are strings or integers, the types permission identifiers use. That keeps every
203
+ backend's answer identical to the reference semantics, which the test suite checks for each one.
204
+
205
+ ## Caching, audit and tracing
206
+
207
+ ```python
208
+ from needtoknow import CachingAuthorizer, JsonLinesAudit
209
+ from needtoknow.otel import OpenTelemetryObserver # pip install "needtoknow[otel]"
210
+
211
+ retriever = PermissionedRetriever(
212
+ backend,
213
+ CachingAuthorizer(authorizer, ttl=30), # a revoked permission stops granting access within 30 s
214
+ observers=[JsonLinesAudit(audit_file), OpenTelemetryObserver()], # audit_file: an open text file
215
+ audit_key=b"...", # optional: an HMAC of each query, so repeats can be matched without storing them
216
+ )
217
+ ```
218
+
219
+ `CachingAuthorizer` caches pre-filters and per-chunk decisions per subject for `ttl` seconds and
220
+ never caches an error; `invalidate()` drops decisions after a change you know about. Every
221
+ retrieval, including failed ones, produces one `RetrievalEvent` (who asked, strategy, returned ids,
222
+ denied and violation counts, duration, error type). Events never contain query or chunk text, and
223
+ an observer that fails is logged without affecting retrieval.
224
+
225
+ ## Bring your own backend or permission system
226
+
227
+ A backend is any object with a `search` method:
228
+
229
+ ```python
230
+ class MyBackend:
231
+ supports_filters = True # set False if search() ignores `filter`
232
+
233
+ def search(self, query: str, k: int, filter=None) -> list[Hit]:
234
+ """Return hits best first. Translate `filter` (In / And / Or) into your store's filter."""
235
+ ```
236
+
237
+ An authorizer answers two questions:
238
+
239
+ ```python
240
+ class MyAuthorizer:
241
+ def check(self, subject, hits, action="read") -> list[bool]:
242
+ """One decision per hit, in order. Raise on any doubt: the retriever fails closed."""
243
+
244
+ def filter_for(self, subject, action="read"):
245
+ """A Filter selecting everything the subject may read, or None if you cannot express one."""
246
+ ```
247
+
248
+ Either can add `async` versions (`asearch`, `acheck`, `afilter_for`); otherwise the sync methods
249
+ run in a thread under `aretrieve`. `FunctionAuthorizer` wraps a plain function.
250
+
251
+ ## Testing approach
252
+
253
+ The guarantees are tested as properties with [Hypothesis](https://hypothesis.readthedocs.io):
254
+ for randomly generated corpora, permissions, users and queries, every strategy must
255
+
256
+ 1. never return a chunk the user may not read,
257
+ 2. return exactly the top-k of what the user may read (checked against an independent oracle),
258
+ 3. stay safe when the backend ignores the filter, and
259
+ 4. give identical results through `retrieve` and `aretrieve`.
260
+
261
+ ## Benchmarks
262
+
263
+ On 57,600 real chunks (BEIR FiQA, Qwen3-Embedding-0.6B), across 33,000 retrievals, no needtoknow
264
+ strategy returned a single forbidden chunk. Filtering inside the search kept recall@10 at 0.949 or
265
+ better on pgvector for users who may read anywhere from 0.06% to 100% of the corpus, including when
266
+ what they may read is far from what they ask about. Filtering after a top-10 search found almost
267
+ nothing for selective permissions. Method, tables and caveats: [Benchmarks](https://muzakkirhussain011.github.io/needtoknow/benchmarks.html).
268
+
269
+ | Readable (far from the query) | Naive top-k, then filter | needtoknow postfilter | needtoknow prefilter (pgvector) |
270
+ |---|---|---|---|
271
+ | 0.9% | 0.000 | 0.048 | 0.995 |
272
+ | 4.8% | 0.000 | 0.314 | 0.950 |
273
+ | 20% | 0.041 | 0.974 | 0.949 |
274
+
275
+ ## Roadmap to 1.0
276
+
277
+ `needtoknow` is in active development towards a 1.0 release with a stable API.
278
+
279
+ - [x] Core: filters, strategies, failing closed, re-checks, async
280
+ - [x] Backends: pgvector, Chroma
281
+ - [x] Authorizers: OPA, OpenFGA
282
+ - [x] Frameworks: LangChain and LlamaIndex retrievers
283
+ - [x] Permission caching with a documented revocation bound, OpenTelemetry hooks, audit events
284
+ - [x] Benchmarks with real embeddings; [documentation site](https://muzakkirhussain011.github.io/needtoknow/)
285
+
286
+ After 1.0: permission sync from Google Drive, SharePoint and Confluence; Qdrant, Elasticsearch,
287
+ SpiceDB and Cerbos adapters. Contributions are welcome; see [CONTRIBUTING.md](https://github.com/muzakkirhussain011/needtoknow/blob/main/CONTRIBUTING.md).
288
+
289
+ ## Example deployment
290
+
291
+ [`examples/gateway`](https://github.com/muzakkirhussain011/needtoknow/tree/main/examples/gateway) is a complete, self-hosted, OpenAI-compatible gateway built
292
+ on the same ideas: OPA policies, pgvector hybrid search with the ACL inside the query, PII
293
+ pseudonymisation, prompt-injection quarantine and a hash-chained audit log. It is being ported onto
294
+ this library.
295
+
296
+ ## License
297
+
298
+ [MIT](https://github.com/muzakkirhussain011/needtoknow/blob/main/LICENSE)
@@ -0,0 +1,252 @@
1
+ # needtoknow
2
+
3
+ [![CI](https://github.com/muzakkirhussain011/needtoknow/actions/workflows/ci.yml/badge.svg)](https://github.com/muzakkirhussain011/needtoknow/actions/workflows/ci.yml)
4
+ [![Docs](https://github.com/muzakkirhussain011/needtoknow/actions/workflows/docs.yml/badge.svg)](https://muzakkirhussain011.github.io/needtoknow/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/muzakkirhussain011/needtoknow/blob/main/LICENSE)
6
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
7
+ ![Dependencies: none](https://img.shields.io/badge/core%20dependencies-none-brightgreen.svg)
8
+
9
+ **Permission-aware retrieval for RAG.** Every user gets only the chunks they are allowed to read,
10
+ whatever vector store, permission system or framework you use.
11
+
12
+ ```python
13
+ from needtoknow import PermissionedRetriever, MetadataACLAuthorizer, Subject
14
+
15
+ retriever = PermissionedRetriever(backend, MetadataACLAuthorizer())
16
+ result = retriever.retrieve("What are this year's salary bands?", Subject("bob", groups=["engineering"]), k=5)
17
+ # result.hits holds only chunks Bob may read. HR documents never reach the prompt.
18
+ ```
19
+
20
+ **[Documentation](https://muzakkirhussain011.github.io/needtoknow/)** ·
21
+ [Security model](https://muzakkirhussain011.github.io/needtoknow/security.html) ·
22
+ [Benchmarks](https://muzakkirhussain011.github.io/needtoknow/benchmarks.html) ·
23
+ [Changelog](https://github.com/muzakkirhussain011/needtoknow/blob/main/CHANGELOG.md)
24
+
25
+ ## Why
26
+
27
+ A RAG assistant over company documents is only as safe as its retrieval step. If the vector search
28
+ ignores who is asking, an engineer can ask the assistant about salaries and get HR documents
29
+ summarised back. Every team building internal RAG has to solve this, and most solve it by hand,
30
+ differently each time.
31
+
32
+ `needtoknow` is that layer as a small, tested library:
33
+
34
+ - **Works with what you have.** Wrap any search backend and any permission system. The core has
35
+ no dependencies.
36
+ - **Filters in the right place.** When the permission system can describe "everything Bob may
37
+ read" as a filter, the filter goes *inside* the vector search, so results are the true top-k of
38
+ what Bob may read. When it cannot, candidates are checked after the search and the search widens
39
+ until enough permitted results are found.
40
+ - **Fails closed.** If the permission service errors, times out or returns malformed answers, the
41
+ user gets an error, never unfiltered results.
42
+ - **Defence in depth.** Pre-filtered results are checked again, so a backend bug that ignores the
43
+ filter cannot leak. Each result reports how many such violations it caught.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install needtoknow
49
+ ```
50
+
51
+ Python 3.10 or newer. Adapters for external systems install as optional extras.
52
+
53
+ ## Quickstart
54
+
55
+ ```python
56
+ from needtoknow import Document, InMemoryBackend, MetadataACLAuthorizer, PermissionedRetriever, Subject
57
+
58
+ backend = InMemoryBackend(
59
+ [
60
+ Document("handbook", "Annual leave is 25 days.", {"allowed_groups": ["*"]}),
61
+ Document("salaries", "Salary bands for 2026 ...", {"allowed_groups": ["hr"]}),
62
+ Document("runbook", "Roll back a failed deploy ...", {"allowed_groups": ["engineering"]}),
63
+ ]
64
+ )
65
+ retriever = PermissionedRetriever(backend, MetadataACLAuthorizer())
66
+
67
+ result = retriever.retrieve("leave salary bands", Subject("bob", groups=["engineering"]), k=3)
68
+ print([hit.id for hit in result.hits]) # ['handbook']
69
+ print(result.strategy, result.complete) # prefilter True
70
+ ```
71
+
72
+ `aretrieve` is the async equivalent, with the same arguments and guarantees.
73
+
74
+ ## How it works
75
+
76
+ | Strategy | When | Cost | Result |
77
+ |---|---|---|---|
78
+ | `prefilter` | The authorizer can return a filter and the backend supports filters | One search | The exact top-k of what the subject may read |
79
+ | `postfilter` | Any authorizer | Searches `k × overfetch` candidates, widening up to `max_candidates` | Exact when enough permitted candidates exist; `complete=False` otherwise |
80
+ | `auto` (default) | Picks `prefilter` when both sides support it | | |
81
+
82
+ Every call returns a `RetrievalResult` with the hits plus `strategy`, `examined`, `denied`,
83
+ `violations` (pre-filtered hits the re-check rejected, which should always be 0) and `complete`.
84
+
85
+ ## Integrations
86
+
87
+ Each integration is an optional extra, so the core stays dependency-free.
88
+
89
+ **PostgreSQL + pgvector** (`pip install "needtoknow[pgvector]"`, pgvector 0.8+). The permission
90
+ filter runs inside the SQL query as JSON containment tests, served by a GIN index, and iterative
91
+ index scans keep HNSW returning k rows under selective filters.
92
+
93
+ ```python
94
+ from psycopg_pool import ConnectionPool
95
+
96
+ from needtoknow.backends.pgvector import PgVectorBackend, create_schema
97
+
98
+ pool = ConnectionPool("postgresql://localhost/app")
99
+ create_schema(pool, dim=768)
100
+ backend = PgVectorBackend(pool, embed=my_embedding_model) # embed: str -> list[float]
101
+ backend.add(documents)
102
+ ```
103
+
104
+ **Chroma** (`pip install "needtoknow[chroma]"`). The filter becomes Chroma's `where` clause. Each
105
+ value in a permission list is stored as its own field rather than as a Chroma array, and an exact
106
+ copy of the metadata is kept for the re-check: in chromadb 1.5.x, deleting a collection leaves its
107
+ array metadata behind for new records in other collections to pick up
108
+ ([chroma-core/chroma#7594](https://github.com/chroma-core/chroma/issues/7594)), which for
109
+ permissions would be a leak.
110
+
111
+ ```python
112
+ import chromadb
113
+
114
+ from needtoknow.backends.chroma import ChromaBackend
115
+
116
+ collection = chromadb.PersistentClient(path="./chroma").get_or_create_collection("docs")
117
+ backend = ChromaBackend(collection) # uses the collection's embedding function
118
+ ```
119
+
120
+ **Open Policy Agent** (no extra dependency). Your Rego policy returns one decision per chunk, and
121
+ can optionally return a filter so the vector store does the filtering. A complete policy is in
122
+ [`examples/opa/needtoknow.rego`](https://github.com/muzakkirhussain011/needtoknow/blob/main/examples/opa/needtoknow.rego).
123
+
124
+ ```python
125
+ from needtoknow.authorizers.opa import OPAAuthorizer
126
+
127
+ authorizer = OPAAuthorizer("http://localhost:8181", filter_path="needtoknow/filter")
128
+ ```
129
+
130
+ **OpenFGA** (no extra dependency). Relationship-based access control in the Google Zanzibar style.
131
+ Checks run in batches; for pre-filtering, ListObjects returns every document the user may view,
132
+ and if that list may have been truncated the retriever post-filters instead. The subject's groups
133
+ can be sent as contextual tuples, so memberships from your identity provider need no syncing.
134
+
135
+ ```python
136
+ from needtoknow.authorizers.openfga import OpenFGAAuthorizer
137
+
138
+ authorizer = OpenFGAAuthorizer("http://localhost:8080", store_id, resource_field="doc_id")
139
+ ```
140
+
141
+ **LangChain** (`pip install "needtoknow[langchain]"`). `NeedToKnowRetriever` is a LangChain
142
+ retriever, so it works in chains, agents and LangGraph tools; `LangChainVectorStoreBackend` turns
143
+ any LangChain vector store into a needtoknow backend (post-filtered).
144
+
145
+ ```python
146
+ from needtoknow.langchain import NeedToKnowRetriever
147
+
148
+ retriever = NeedToKnowRetriever(retriever=PermissionedRetriever(backend, authorizer))
149
+ docs = retriever.invoke("What are this year's salary bands?", subject=Subject("bob", ["sales"]))
150
+ ```
151
+
152
+ **LlamaIndex** (`pip install "needtoknow[llamaindex]"`). `NeedToKnowRetriever` for query and chat
153
+ engines, `PermissionNodePostprocessor` for existing pipelines, and `LlamaIndexBackend` to wrap an
154
+ existing index. Runnable examples for both frameworks are in [`examples/`](https://github.com/muzakkirhussain011/needtoknow/tree/main/examples).
155
+
156
+ Filter values are strings or integers, the types permission identifiers use. That keeps every
157
+ backend's answer identical to the reference semantics, which the test suite checks for each one.
158
+
159
+ ## Caching, audit and tracing
160
+
161
+ ```python
162
+ from needtoknow import CachingAuthorizer, JsonLinesAudit
163
+ from needtoknow.otel import OpenTelemetryObserver # pip install "needtoknow[otel]"
164
+
165
+ retriever = PermissionedRetriever(
166
+ backend,
167
+ CachingAuthorizer(authorizer, ttl=30), # a revoked permission stops granting access within 30 s
168
+ observers=[JsonLinesAudit(audit_file), OpenTelemetryObserver()], # audit_file: an open text file
169
+ audit_key=b"...", # optional: an HMAC of each query, so repeats can be matched without storing them
170
+ )
171
+ ```
172
+
173
+ `CachingAuthorizer` caches pre-filters and per-chunk decisions per subject for `ttl` seconds and
174
+ never caches an error; `invalidate()` drops decisions after a change you know about. Every
175
+ retrieval, including failed ones, produces one `RetrievalEvent` (who asked, strategy, returned ids,
176
+ denied and violation counts, duration, error type). Events never contain query or chunk text, and
177
+ an observer that fails is logged without affecting retrieval.
178
+
179
+ ## Bring your own backend or permission system
180
+
181
+ A backend is any object with a `search` method:
182
+
183
+ ```python
184
+ class MyBackend:
185
+ supports_filters = True # set False if search() ignores `filter`
186
+
187
+ def search(self, query: str, k: int, filter=None) -> list[Hit]:
188
+ """Return hits best first. Translate `filter` (In / And / Or) into your store's filter."""
189
+ ```
190
+
191
+ An authorizer answers two questions:
192
+
193
+ ```python
194
+ class MyAuthorizer:
195
+ def check(self, subject, hits, action="read") -> list[bool]:
196
+ """One decision per hit, in order. Raise on any doubt: the retriever fails closed."""
197
+
198
+ def filter_for(self, subject, action="read"):
199
+ """A Filter selecting everything the subject may read, or None if you cannot express one."""
200
+ ```
201
+
202
+ Either can add `async` versions (`asearch`, `acheck`, `afilter_for`); otherwise the sync methods
203
+ run in a thread under `aretrieve`. `FunctionAuthorizer` wraps a plain function.
204
+
205
+ ## Testing approach
206
+
207
+ The guarantees are tested as properties with [Hypothesis](https://hypothesis.readthedocs.io):
208
+ for randomly generated corpora, permissions, users and queries, every strategy must
209
+
210
+ 1. never return a chunk the user may not read,
211
+ 2. return exactly the top-k of what the user may read (checked against an independent oracle),
212
+ 3. stay safe when the backend ignores the filter, and
213
+ 4. give identical results through `retrieve` and `aretrieve`.
214
+
215
+ ## Benchmarks
216
+
217
+ On 57,600 real chunks (BEIR FiQA, Qwen3-Embedding-0.6B), across 33,000 retrievals, no needtoknow
218
+ strategy returned a single forbidden chunk. Filtering inside the search kept recall@10 at 0.949 or
219
+ better on pgvector for users who may read anywhere from 0.06% to 100% of the corpus, including when
220
+ what they may read is far from what they ask about. Filtering after a top-10 search found almost
221
+ nothing for selective permissions. Method, tables and caveats: [Benchmarks](https://muzakkirhussain011.github.io/needtoknow/benchmarks.html).
222
+
223
+ | Readable (far from the query) | Naive top-k, then filter | needtoknow postfilter | needtoknow prefilter (pgvector) |
224
+ |---|---|---|---|
225
+ | 0.9% | 0.000 | 0.048 | 0.995 |
226
+ | 4.8% | 0.000 | 0.314 | 0.950 |
227
+ | 20% | 0.041 | 0.974 | 0.949 |
228
+
229
+ ## Roadmap to 1.0
230
+
231
+ `needtoknow` is in active development towards a 1.0 release with a stable API.
232
+
233
+ - [x] Core: filters, strategies, failing closed, re-checks, async
234
+ - [x] Backends: pgvector, Chroma
235
+ - [x] Authorizers: OPA, OpenFGA
236
+ - [x] Frameworks: LangChain and LlamaIndex retrievers
237
+ - [x] Permission caching with a documented revocation bound, OpenTelemetry hooks, audit events
238
+ - [x] Benchmarks with real embeddings; [documentation site](https://muzakkirhussain011.github.io/needtoknow/)
239
+
240
+ After 1.0: permission sync from Google Drive, SharePoint and Confluence; Qdrant, Elasticsearch,
241
+ SpiceDB and Cerbos adapters. Contributions are welcome; see [CONTRIBUTING.md](https://github.com/muzakkirhussain011/needtoknow/blob/main/CONTRIBUTING.md).
242
+
243
+ ## Example deployment
244
+
245
+ [`examples/gateway`](https://github.com/muzakkirhussain011/needtoknow/tree/main/examples/gateway) is a complete, self-hosted, OpenAI-compatible gateway built
246
+ on the same ideas: OPA policies, pgvector hybrid search with the ACL inside the query, PII
247
+ pseudonymisation, prompt-injection quarantine and a hash-chained audit log. It is being ported onto
248
+ this library.
249
+
250
+ ## License
251
+
252
+ [MIT](https://github.com/muzakkirhussain011/needtoknow/blob/main/LICENSE)