Operations#
Running needtoknow in production: fewer calls to the permission service, an audit trail, traces, metrics, and the signals worth alerting on.
from needtoknow import CachingAuthorizer, JsonLinesAudit, PermissionedRetriever
from needtoknow.otel import OpenTelemetryMetrics, OpenTelemetryObserver # pip install "needtoknow[otel]"
retriever = PermissionedRetriever(
backend,
CachingAuthorizer(authorizer, ttl=30),
observers=[JsonLinesAudit(audit_file), OpenTelemetryObserver(), OpenTelemetryMetrics()],
audit_key=audit_key, # bytes from your secret store; enables query fingerprints
)
Permission caching#
A remote permission service (OPA, OpenFGA) adds a network round trip to every retrieval.
CachingAuthorizer wraps any authorizer and remembers its answers.
from needtoknow import CachingAuthorizer
authorizer = CachingAuthorizer(openfga, ttl=30, maxsize=100_000)
What is cached: per subject and action, the pre-filter from
filter_forand eachcheckdecision. A decision is keyed by the chunk id and its metadata, so a chunk whose permission metadata changed is decided again.The revocation bound: nothing older than
ttlseconds is used. A permission revoked at the source stops granting access withinttlseconds, plus whatever delay the permission service itself has. Pickttlas a security decision: 30 seconds is a common trade-off, and a few seconds still removes most repeated calls within one conversation.Never cached: exceptions and malformed answers (non-booleans, the wrong number of decisions). Every failure reaches the retriever, which fails closed.
Size: at most
maxsizeentries; the least recently used go first.Monitoring:
stats()returns aCacheStatswith the answers served from this process (hits) and from the shared cache (shared_hits), those the authorizer was asked for (misses), the shared cache reads and writes that failed (shared_failures), and the answers held now (entries). Export them as gauges to watch the hit rate.Invalidation:
invalidate(subject)drops the entries of every subject with that id, whatever groups and attributes they came with (pass theSubjector just its id), for example after a role change you handled yourself;invalidate()drops everything. An answer the permission service gave before the call, to a retrieval still in progress, is not cached after it: onceinvalidate()returns, no retrieval that starts in this process uses an answer from before it.
It works for retrieve and aretrieve, using the wrapped authorizer’s async methods when it has
them.
Changing a document’s permissions#
When a document is shared with someone new, or access to it is taken away, its chunks’ metadata
has to change before searches can see it. set_metadata replaces the metadata of stored chunks
without embedding them again:
backend.set_metadata({chunk_id: {"allowed_groups": ["hr"]} for chunk_id in chunk_ids})
The whole metadata is replaced: no key of the old permissions survives. Ids that are not stored are ignored, so nothing is created from metadata alone.
It returns once searches see the change, as
adddoes for that store (Choosing a store); MongoDB waits for its search index.Every built-in backend has it, and the conformance kit checks it against each real store. Keep each source document’s chunk ids (for example
"handbook#12") so you can find them when its permissions change.With hybrid search over several indexes, update each of them; until they agree,
RankFusionleaves the chunk out.To copy sharing from Google Drive,
GoogleDriveACLreads who may read a file andwith_accesswrites it into the chunks’ metadata (Google Drive).
A change on the other side, such as a user leaving a group, needs no update to the chunks: the
subject’s groups are read at query time. CachingAuthorizer keys each decision by the chunk’s
metadata, so a chunk whose metadata changed is decided again at once, and cached filters expire
after ttl.
Resilience of remote permission services#
OPAAuthorizer and OpenFGAAuthorizer share an HTTP client built for permission checks on every
retrieval:
Connections are reused, one per thread, so a busy service does not open a TCP and TLS connection per check. A connection the server closed while idle is replaced transparently.
Transient failures are retried once (
retries=1): refused or reset connections, responses that never arrived, and HTTP 502, 503 and 504. The requests are queries, so a retry has no side effects. Timeouts are not retried, which keeps the worst case at onetimeout; neither are other HTTP errors or invalid answers.A circuit breaker pauses requests to a service that keeps failing, so retries do not pile on during an outage. Retrieval fails closed either way; the breaker makes it fail fast.
Redirects are never followed, and the standard
HTTP_PROXY,HTTPS_PROXYandNO_PROXYvariables are honoured (proxied requests do not reuse connections).TLS uses the system trust store; pass
ssl_contextfor your own CA or for mutual TLS.
import ssl
from needtoknow.authorizers import CircuitBreaker
from needtoknow.authorizers.openfga import OpenFGAAuthorizer
tls = ssl.create_default_context(cafile="/etc/ssl/internal-ca.pem")
tls.load_cert_chain("/etc/needtoknow/client.pem", "/etc/needtoknow/client.key")
authorizer = OpenFGAAuthorizer(
"https://openfga.internal:8443",
store_id,
timeout=1.0,
circuit_breaker=CircuitBreaker(failures=5, reset_after=30),
ssl_context=tls,
)
CircuitBreaker(failures=5, reset_after=30) opens after five consecutive failed requests (no
response, or a 5xx one), rejects calls at once for 30 seconds, then lets one probe through: success
closes it, failure opens it again. breaker.is_open is there for monitoring, and one breaker can be
shared by every authorizer that calls the same service.
Subjects in very many groups#
In large organisations one user can belong to thousands of directory groups. With
MetadataACLAuthorizer the pre-filter names all of them, and with OpenFGA or SpiceDB it lists every
document the user may read, so filters can grow large. How each backend sends a large filter, and
what CI tests:
Backend |
A large |
Tested in CI |
|---|---|---|
pgvector |
Up to 100 values: one GIN-indexed containment test per value. Above that: two array parameters, so the query stays small and far below PostgreSQL’s limit of 65,535 parameters (the GIN index does not serve that clause; the HNSW scan filters as it goes) |
a subject in 40,000 groups |
Qdrant |
One |
10,000 groups |
Elasticsearch, OpenSearch |
One |
10,000 groups |
Chroma |
|
300 values |
OpenFGA, SpiceDB |
The pre-filter lists documents, up to |
Every backend also runs the conformance kit with filters of 300 values and subjects in hundreds of groups. To keep filters small:
Copy only the groups that grant access to documents into the chunks’ permissions, and give the
Subjectonly those groups; most directory groups (distribution lists, building access) are irrelevant to retrieval.Use coarser groups where the source allows it: one department rather than its fifty teams.
Wrap the authorizer in
CachingAuthorizer, so a large filter is computed once perttl.
Audit events#
Every retrieve and aretrieve call, including failed ones, produces one RetrievalEvent and
passes it to each observer. An observer is any callable taking the event.
Field |
Meaning |
|---|---|
|
Who asked, for what, and how many results |
|
|
|
The chunk ids returned, in order |
|
As in |
|
Timing ( |
|
Time spent in the backend and in the authorizer |
|
Backend searches: one when pre-filtering, more when post-filtering fetched again |
|
Chunks given to the reranker (0 without one), and the time it took |
|
The exception type when the call failed, for example |
|
The type of the exception behind |
|
HMAC-SHA256 of the query, only when |
Events never contain query or chunk text, nor a query’s vector. A plain hash of a short query could be reversed by guessing, so the fingerprint is an HMAC under your key, and there is none without a key. Equal queries under the same key have equal fingerprints, which lets you spot repeated or probing queries without storing them. Rotating the key breaks that link, which can be what you want.
JsonLinesAudit(stream) writes one JSON object per line to any text stream, thread-safely, and
flushes after each event:
{"action":"read","authorize_ms":0.18,"complete":true,"denied":0,"duration_ms":4.21,"error":null,"examined":2,"k":5,"query_hmac":"9f2c...","rerank_ms":0.0,"reranked":0,"returned_ids":["handbook","policy-7"],"search_ms":3.87,"searches":1,"started_at":1792152000.12,"strategy":"prefilter","subject_id":"bob","violations":0}
An observer that raises is logged on the needtoknow logger and never changes what retrieval
returns or raises.
Tracing#
OpenTelemetryObserver records each retrieval as a span named needtoknow.retrieve, timed from
the call and parented to the span that was current when it ran.
Attribute |
Value |
|---|---|
|
|
|
The |
|
As requested |
|
The strategy that ran |
|
Counts |
|
As |
|
Backend searches made, and the time spent in the backend and in the authorizer |
|
Chunks reranked and the reranker’s time, on reranked retrievals only |
|
The exception type; the span status is then ERROR |
|
The type of the exception behind it, as |
|
Only with |
The gen_ai.* and error.type attributes are the ones OpenTelemetry’s GenAI semantic conventions
define for retrieval. Those conventions were still at Development status in October 2026 and may
change, so everything else uses the needtoknow. prefix, and the span keeps its name. It needs only
opentelemetry-api; your application configures the SDK and exporter as usual.
Metrics#
OpenTelemetryMetrics turns the same events into metrics for dashboards and alerts:
from needtoknow.otel import OpenTelemetryMetrics, OpenTelemetryObserver
observers = [OpenTelemetryObserver(), OpenTelemetryMetrics()]
retriever = PermissionedRetriever(backend, authorizer, observers=observers)
Instrument |
Type, unit |
What it measures |
|---|---|---|
|
Histogram, s |
Each retrieval, with |
|
Counter |
Retrievals by |
|
Histogram, s |
Time spent in the backend during one retrieval |
|
Histogram, s |
Time spent in the authorizer during one retrieval |
|
Histogram, s |
Time spent in the reranker, on reranked retrievals only |
|
Counters |
Chunks returned, checked by the authorizer, and withheld |
|
Counter |
Pre-filtered chunks the re-check rejected |
Every instrument is broken down by needtoknow.action and needtoknow.strategy, plus the outcome
and error.type where they apply. Each has a handful of values, so the number of time series stays
small however many users you have. Subjects, queries and chunks are never recorded; per-subject
questions belong to the audit log. The duration histogram uses the bucket boundaries the
conventions advise (10 ms to 82 s); the two stage histograms start at 0.5 ms, because a cached
authorizer answers in well under a millisecond. A View in your SDK configuration can change
either.
What to alert on#
Signal |
Metric |
Why |
|---|---|---|
Any violation |
|
The backend returned chunks outside the filter. Nothing leaked, because the re-check caught them, but the backend or its translation has a bug. |
Authorization failures |
|
The permission service is failing. Users are getting errors, which is the safe outcome, but it is an outage. |
Incomplete results |
Share of |
Post-filtering is hitting |
Slow permission checks |
|
The permission service, or the network to it, is slowing retrieval down. |
Probing |
|
Unusually high values can mean a user is probing for documents they may not read. |
Which part is failing |
|
|
Shared cache failing |
|
Every replica is asking the permission service directly; answers stay correct, but load and latency rise. |
Cache not helping |
Hit rate, |
|
Paused permission service |
|
Requests are being refused without calling the service, for |
When one of these fires, the incident runbook has the steps, containment first.