Combining authorizers and multi-tenancy#

Real permission models have layers: a SaaS product must never return one customer’s documents to another, and inside a customer, documents are shared through groups, an OpenFGA model or a policy. AllOf, AnyOf and TenantAuthorizer compose those layers without writing your own authorizer.

Tenant isolation#

from needtoknow import AllOf, PermissionedRetriever, Subject, TenantAuthorizer
from needtoknow.authorizers.openfga import OpenFGAAuthorizer

authorizer = AllOf(TenantAuthorizer(), OpenFGAAuthorizer(url, store_id))
retriever = PermissionedRetriever(backend, authorizer)

subject = Subject(user.id, user.groups, {"tenant": user.tenant_id})
result = retriever.retrieve(question, subject, k=5)

TenantAuthorizer(field="tenant", attribute="tenant") allows a chunk only when its metadata field field holds the subject’s attribute attribute. Put the tenant id on every chunk at ingestion. A subject without the attribute raises AuthorizationError: a request that lost its tenant returns nothing, never everything.

AllOf and AnyOf#

A chunk is readable when

Pre-filter

Scope

AllOf(a, b, ...)

every authorizer allows it

And of their filters, if all have one

And of the filters that exist

AnyOf(a, b, ...)

at least one allows it

Or of their filters, if all have one

Or of each one’s filter or scope, if all have one

Authorizers are asked in order. AllOf asks each one only about the chunks every earlier one allowed, and AnyOf only about the chunks every earlier one denied, so put the cheap, local ones first and remote services last: with AllOf(TenantAuthorizer(), OpenFGAAuthorizer(...)), OpenFGA is never asked about another tenant’s chunks. Every part must answer well: a wrong number of decisions or a non-boolean from any of them raises AuthorizationError.

from needtoknow import AnyOf, MetadataACLAuthorizer
from needtoknow.authorizers.opa import OPAAuthorizer

# Shared directly on the document, or allowed by policy.
authorizer = AnyOf(MetadataACLAuthorizer(), OPAAuthorizer(opa_url, filter_path="needtoknow/filter"))

Scoped post-filtering#

When the combination has an exact filter, the retriever pre-filters as usual. When one part has none (a FunctionAuthorizer with only check, or OpenFGA when its list of documents may be truncated), AllOf cannot give an exact filter. It can still give a scope: the conjunction of the filters that do exist, which every readable chunk matches. The retriever then post-filters inside the scope: it searches only the subject’s tenant and checks every candidate.

That matters at scale. If a tenant holds 1% of the index, post-filtering across the whole index examines about a hundred chunks of other tenants for every one of its own and, within max_candidates, finds little or nothing; post-filtering inside the tenant examines only its chunks. Results are exactly right either way, because every candidate is still checked.

Any authorizer can offer a scope by implementing scope_for(subject, action) -> Filter | None; CachingAuthorizer caches it like filter_for. AllOf computes its scope by asking each part for its filter again, so wrap remote parts in CachingAuthorizer to keep that to one call.

Testing a combination#

from needtoknow.testing import check_authorizer

check_authorizer(AllOf(TenantAuthorizer(), MetadataACLAuthorizer()), subjects, hits)

checks that the combined check and filter_for agree for your subjects and chunks.