Open Policy Agent#
No extra dependency: requests use the standard library.
from wardowl.authorizers.opa import OPAAuthorizer
authorizer = OPAAuthorizer("http://localhost:8181", filter_path="wardowl/filter")
Your Rego policy decides; wardowl asks. A complete, tested policy is in
examples/opa/wardowl.rego:
opa run --server examples/opa
That loads the policy from files, which OPA reads again whenever it starts. Policies and data
uploaded through OPA’s REST API (PUT /v1/policies/..., PUT /v1/data/...) are kept in OPA’s
memory by default, and an OPA that restarts without them has lost them. A lost policy leaves every
decision undefined, so every retrieval raises AuthorizationError until it is uploaded again; lost
data is worse, since the policy still decides, without it, and can allow what the data used to
forbid. In production, load both from files or from a
bundle, which OPA downloads from your
server and polls for changes.
Decisions#
check queries decisions_path (default wardowl/decisions) with this input:
{
"subject": {"id": "bob", "groups": ["engineering"], "attributes": {}},
"action": "read",
"resources": [{"id": "handbook-1", "metadata": {"allowed_groups": ["*"]}}]
}
and expects a list of booleans, one per resource, in order. In the example policy:
decisions := [allowed(r) | some r in input.resources]
Filters for pre-filtering#
If you set filter_path, filter_for queries it with {"subject": ..., "action": ...} and expects
a filter in JSON form, or null when the policy cannot express the subject’s access as a filter:
filter := {"or": [
{"in": {"field": "allowed_users", "values": [input.subject.id]}},
{"in": {"field": "allowed_groups", "values": array.concat(input.subject.groups, ["*"])}},
]}
The JSON form is {"in": {"field": ..., "values": [...]}}, {"and": [...]} or {"or": [...]},
the same as filter_to_dict. The filter must select exactly what decisions allows, since the
vector store applies the filter and decisions re-checks the results. If the two disagree, the
re-check removes the extra hits and counts them as violations.
With PermissionedRetriever(recheck=False) there is no re-check: a pre-filtered hit is only
matched against the filter, so the filter alone decides. In matches a list field when any of its
elements is among the values, and a single value when it is, even where your Rego treats a field of
that shape differently. Every chunk must then store each field in the shape the policy assumes (a
list for allowed_groups, one string for a document id); keep recheck=True, the default, unless
that holds for every chunk.
Failing closed#
Every failure raises AuthorizationError: an unreachable server, a timeout, a non-200 response,
invalid JSON, an answer larger than max_response_bytes= (10 MiB by default), an undefined
decision, a list of the wrong length or type, or a malformed filter. An undefined decision is
treated as a deny, never an allow. timeout= (2 seconds by default) bounds the whole call:
connecting to each address of the host, the TLS handshake, sending, reading every byte of the
answer, and any retry. Looking up the host’s addresses (DNS) is the one step it cannot cut short,
though the time it takes counts: the standard library has no timeout for it, and a lookup moved to a
thread could not be stopped, so it would hold the thread until the system resolver gave up. Bound it
with the resolver’s own settings (options timeout: and attempts: in /etc/resolv.conf), or name
the server by IP address. Connections are reused, so a lookup happens only when one is opened.
headers= adds headers to every request, for example {"Authorization": "Bearer ..."} when OPA
requires a token. An Authorization header is refused with ValueError over http:// unless the
url names this machine (localhost, 127.0.0.1, ::1) and no proxy applies: use https://, or
pass allow_insecure=True on a network you trust. Connections are reused and transient failures
retried once (retries=); a circuit_breaker= pauses requests to a failing server, and
ssl_context= sets your own CA or mutual TLS for an https:// url (it is refused with http://,
where it would not be used). A connection is never shared across fork(): a worker forked after a
request opens its own. See resilience.