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.