Concepts#
needtoknow has four parts: the subject who asks, the backend that searches, the authorizer that decides, and the retriever that combines them and enforces the guarantees.
Subjects#
A Subject is who is asking: a user id, the groups they belong to, and any attributes your
policy needs.
from needtoknow import Subject
alice = Subject("alice", groups=["hr", "managers"], attributes={"region": "emea"})
The id must not be empty, and groups must be a collection: Subject("bob", "eng") raises
instead of quietly becoming the groups e, n and g. Subjects are immutable and hashable,
which is what lets CachingAuthorizer key its cache by
them.
Documents and hits#
A Document is a chunk as stored: an id, its text and its metadata. A Hit is a search result:
the same fields plus the backend’s relevance score, where higher is better. Permissions usually
live in the metadata, for example {"allowed_groups": ["hr"]} or {"doc_id": "handbook-2026"}
for a permission system that decides per source document.
Filters#
Filters are a small language every backend can translate: In, And and Or.
from needtoknow import And, In, Or
f = Or(
In("allowed_users", ["alice"]),
And(In("allowed_groups", ["hr", "*"]), In("region", ["emea"])),
)
In(field, values) is true when the metadata field equals one of the values, or, for a list
field such as allowed_groups: ["sales", "hr"], when the two share an element. The rules are
strict on purpose, because a permission filter that is too generous is a leak:
A missing field never matches. Missing permissions mean no access.
Values are strings or integers only. Metadata values of other types (booleans, floats, nested objects) never match. Python treats
True == 1, JSON stores compare by type, and keeping to two types gives every backend the same answer.And()andOr()with no clauses are rejected. An emptyAndwould be true for everything.In(field, [])matches nothing.
matches(f, metadata) is the reference semantics. Every backend’s translation is tested against
it. filter_to_dict and filter_from_dict convert filters to and from JSON, which is how
OPAAuthorizer receives them from a policy.
Strategies#
PermissionedRetriever(backend, authorizer, strategy=...) decides where permissions are applied.
Strategy |
When it is used |
Cost |
Result |
|---|---|---|---|
|
The authorizer returns a filter and the backend supports filters |
One search |
The exact top-k of what the subject may read, as far as the index is exact |
|
Any authorizer and backend |
|
Exact when enough permitted candidates are found; |
|
|
Pre-filtering puts the filter inside the search. The vector store only ranks chunks the subject may read, so a subject who may read 0.1% of the corpus still gets ten good results, not the permitted few among the global top ten.
Post-filtering searches without a filter and checks the candidates. It starts with
k × overfetch candidates (default 4), and while fewer than k are permitted it multiplies the
fetch by overfetch and searches again, until k are found, the backend runs out, or
max_candidates (default 1000) is reached. The last case is reported as complete=False, so you
know the result may be missing permitted chunks rather than finding out from users. Subjects who
may read only a small share of the corpus are where post-filtering struggles; the
benchmarks show by how much.
Choose prefilter explicitly when you want an error rather than a silent fallback if a filter is
not available.
What a result tells you#
Field |
Meaning |
|---|---|
|
Permitted chunks, best first, at most |
|
The strategy that actually ran |
|
|
|
Candidates the authorizer checked |
|
Post-filtered candidates the subject may not read |
|
Pre-filtered hits the re-check rejected |
violations should always be 0. Anything else means the backend returned chunks outside the
filter, and deserves an alert: the re-check removed them, but the backend has a bug.
complete=False means needtoknow knows results may be missing: post-filtering stopped at
max_candidates, or the re-check removed hits. complete=True trusts the backend’s ranking. An
approximate index (HNSW and similar) can return fewer or different results than an exact search,
and no caller can tell that apart from “the user may read fewer than k chunks”. How much each
backend misses is measured in the benchmarks; for pgvector, keep iterative index
scans on (the default), which is what keeps a selective filter from cutting results short.
The guarantees#
Fail closed. If the authorizer raises, times out, returns the wrong number of decisions, or
returns anything that is not a real bool ("no", 1, None), the call raises
AuthorizationError. There is no fallback to unfiltered results.
Defence in depth. With recheck=True (the default), pre-filtered hits are checked again with
authorizer.check. A backend that ignores or mistranslates the filter cannot leak; the rejected
hits are dropped and counted in violations. With recheck=False the authorizer is not asked
again, but each pre-filtered hit is still matched against the filter in the process: a backend
that ignores or mistranslates the filter still cannot leak, though a filter broader than the
authorizer’s own check would go unnoticed.
One algorithm, two drivers. retrieve and aretrieve run the same plan, written once, so the
async path cannot drift from the sync one. Backends and authorizers may provide asearch,
afilter_for and acheck; otherwise their sync methods run in a thread.
These guarantees are tested as properties with Hypothesis: for random corpora, permissions, subjects and queries, no strategy may return a chunk the subject may not read, results must equal an independent oracle’s top-k, and a backend that ignores the filter must still not leak.