Operations#
Running wardowl in production: fewer calls to the permission service, an audit trail, traces, metrics, and the signals worth alerting on.
from wardowl import CachingAuthorizer, JsonLinesAudit, PermissionedRetriever
from wardowl.otel import OpenTelemetryMetrics, OpenTelemetryObserver # pip install "wardowl[otel]"
retriever = PermissionedRetriever(
backend,
CachingAuthorizer(authorizer, ttl=30),
observers=[JsonLinesAudit(audit_file), OpenTelemetryObserver(), OpenTelemetryMetrics()],
audit_key=audit_key, # at least 16 random 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 wardowl 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. Keys encode every value with its type, so1,"1",Trueand1.0are different subjects’ attributes or chunks’ metadata. A subject or chunk holding a value of another type than strings, numbers, booleans, None, bytes, dates, times, decimals, UUIDs and lists, tuples, sets and mappings of them is never cached: the authorizer is asked every time.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, a filter that is not a well-formed
In,AndorOr). 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:
# `stored` is the metadata your ingestion wrote for each chunk: its tenant, source, redactions.
backend.set_metadata({chunk_id: {**stored[chunk_id], "allowed_groups": ["hr"]} for chunk_id in chunk_ids})
The whole metadata is replaced: no key of the old permissions survives. Pass every field the chunk keeps, not only the one that changed: a chunk written back without its
tenantmatches noTenantAuthorizerfilter, and one without itsredactionsis hidden whole bySpanRedactor. 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#
The HTTP authorizers, OPAAuthorizer, OpenFGAAuthorizer, SpiceDBAuthorizer, CerbosAuthorizer
and KetoAuthorizer, 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, and neither are other HTTP errors or invalid answers, so the worst case is onetimeout, plus the DNS lookup when a connection is opened, which only the system resolver’s settings bound (see OPA).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 wardowl.authorizers import CircuitBreaker
from wardowl.authorizers.openfga import OpenFGAAuthorizer
tls = ssl.create_default_context(cafile="/etc/ssl/internal-ca.pem")
tls.load_cert_chain("/etc/wardowl/client.pem", "/etc/wardowl/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. A call it rejects raises AuthorizationError
caused by wardowl.authorizers.CircuitOpenError, so RetrievalEvent.cause says
"CircuitOpenError".
Every AuthorizationError carries a fixed message, such as “authorizer failed” or “OPA decision
failed: ConnectionRefusedError”, never the text of the exception behind it: that text can name
hosts, users or credentials, and agent frameworks show a tool’s error to the model. The exception
itself is the error’s __cause__, so a log that records the traceback (logging.exception, or the
HTTP service’s log) keeps the details, and RetrievalEvent.cause names its type.
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. A key shorter than 16 bytes is refused, as a guessable key would make the fingerprints guessable too. 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 wardowl logger and never changes what retrieval
returns or raises.
Tracing#
OpenTelemetryObserver records each retrieval as a span named wardowl.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 wardowl. 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 wardowl.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 wardowl.action and wardowl.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.