Security model#
This page states what needtoknow guarantees, what it relies on, and what stays your responsibility. Read it before putting needtoknow in front of confidential documents.
What needtoknow guarantees#
Given an authorizer that answers correctly:
No denied chunk is returned. Every hit in a result was either selected by the authorizer’s filter and re-checked, or checked one by one. This holds for
retrieveandaretrieve, and for the LangChain and LlamaIndex retrievers built on them.Failures return nothing. An authorizer that raises, times out, returns the wrong number of decisions or returns non-boolean decisions makes the call raise
AuthorizationError. There is no fallback to unfiltered results, and aPermissionNodePostprocessorin a LlamaIndex pipeline behaves the same way.A backend’s filtering bugs cannot leak. With
recheck=True(the default), pre-filtered hits are checked again, and anything the backend returned outside the filter is dropped and counted inviolations. Withrecheck=False, hits are still matched against the filter locally, at no cost, so a backend that ignores it cannot leak either.Incomplete results are reported. When post-filtering stops at
max_candidatesbefore finding k permitted hits, the result sayscomplete=False.Audit events contain no content.
RetrievalEvent,JsonLinesAuditand the OpenTelemetry spans record ids, counts and outcomes, never query text or chunk text. The query appears only as an HMAC, and only when you supply a key. OpenTelemetry metrics record no ids at all.
The invariants and the tests that hold them#
These are the properties a change must never break. Each is checked by automated tests in CI, against the in-memory reference and, where it matters, against the real stores and permission services. The tests compare with an oracle written without the library’s own filter code. The threat model says which threat each one answers.
Invariant |
Held by |
|
|---|---|---|
S1 |
Every returned hit is permitted for the subject and action. |
|
S2 |
No failure, timeout, malformed or partial answer returns data. |
|
S3 |
A subject of one tenant never receives another tenant’s chunk, even with shared user ids, group names and document ids, and one cache for all. |
|
S4 |
Decisions never carry over between requests, users, tenants, threads or tasks; a cancelled call returns nothing. |
|
S5 |
An optimization that fails keeps the guarantees or fails. |
A backend that ignores the filter cannot leak, with or without |
S6 |
Content the subject may not read never reaches a reranker, the model or a tool. |
|
S7 |
An incomplete candidate search is never reported as complete. |
|
S8 |
The documented defaults keep every protection on. |
|
S9 |
Cached decisions are used for at most |
|
S10 |
Errors and telemetry reveal no content, credentials or query text. |
|
S1 to S10 assume the authorizer answers correctly, the backend returns the metadata it stored, and
the subject comes from authentication; the next sections cover those assumptions. The test files
are in tests/.
What it relies on#
The authorizer is the source of truth. needtoknow enforces decisions; it does not make them. If your policy or your relationship tuples grant too much, needtoknow returns too much.
Permission metadata must be correct and current. With MetadataACLAuthorizer and
TenantAuthorizer, and with any authorizer that reads chunk metadata (OPAAuthorizer and
CerbosAuthorizer send it to the policy, OpenFGAAuthorizer and SpiceDBAuthorizer read the
resource id from it), the permissions copied onto chunks at ingestion time are what get enforced.
When a document’s sharing changes, re-index its chunks. How quickly you do that is your revocation
delay for those chunks.
The backend returns the metadata it stored. The re-check protects against a backend that ignores
the filter, not against one that returns different metadata than it stored. The pgvector backend
returns the stored jsonb column, Qdrant the point’s payload, and Elasticsearch and OpenSearch the
document’s _source, where metadata is stored unindexed. The Chroma backend rebuilds metadata from
an exact JSON copy rather than from Chroma’s own fields, because of a Chroma bug that can attach a
deleted collection’s metadata to new records (details).
Elasticsearch and OpenSearch filter on a separate field, needtoknow_terms, but the re-check reads
the metadata, so if the two ever disagree a chunk can be hidden, never leaked.
Caching delays revocation by at most ttl. CachingAuthorizer keeps decisions for ttl
seconds (30 by default), so a revoked permission can keep granting access for up to that long.
Choose ttl as a policy decision, and call invalidate() when you know permissions changed.
A shared cache is trusted with decisions. With CachingAuthorizer(shared=RedisCache(...)),
whoever can write to that Redis can plant a decision, unless you set signing_key: then every value
carries an HMAC-SHA256 over its key and content, and anything unsigned, tampered with or copied
under another key is ignored. Keep that Redis private to the application either way. Key names are
digests, so the subjects and chunks are not readable from a key listing. A failing Redis is only
ever a miss. invalidate() clears Redis at once and reaches other replicas within local_ttl (1
second by default).
A scope only narrows the search. scope_for (from TenantAuthorizer inside AllOf, for
example) limits which chunks post-filtering searches, and every candidate is still checked. A scope
wider than the truth costs only speed; a scope narrower than the truth hides chunks the subject may
read, but never returns one it may not.
The subject must come from authentication#
The most common way to defeat permission-aware retrieval is to let the wrong party choose the
subject. Build the Subject from your authenticated session: the user id and groups your identity
provider vouches for.
Never read the subject from the request body or from query parameters the client controls.
Never let a language model choose it. In an agent, bind the subject when the tool is created for the session (for example
NeedToKnowRetriever(...).for_subject(subject)in LangChain); do not expose it as a tool argument the model fills in.Group memberships sent as OpenFGA contextual tuples are trusted as given, so they too must come from your identity provider, not from the client.
What it does not do#
Output control. needtoknow decides which chunks reach the prompt. It does not stop a model from repeating a permitted chunk’s content, and it does not filter model output.
Prompt injection. A chunk the user may read can still contain instructions aimed at the model. That is a separate problem with separate controls.
Side channels. examined, denied and post-filtering latency depend on how many forbidden
chunks matched the query, so they can hint that relevant documents exist. Keep them in your logs
and metrics; do not show them to end users. In keyword search, the store’s BM25 statistics also
count chunks the subject may not read, so the order and scores of what they may read can reflect
what forbidden chunks contain (threat model, T17): do not show keyword scores to
end users, or give each tenant its own keyword index.
Query privacy. Queries still go to your embedding model and vector store. Audit events omit them, but the rest of your stack sees them.
Authentication and transport security. Use TLS and credentials for your permission service,
your vector store and Redis. The HTTP authorizers (OPA, OpenFGA, SpiceDB, Cerbos) accept headers
for an Authorization header (SpiceDBAuthorizer also takes its preshared key as token) and
refuse URLs that are not http:// or https://.
Reporting a vulnerability#
Please report security problems privately, as described in SECURITY.md, not in public issues.