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 |
|
|---|---|---|---|
|
every authorizer allows it |
|
|
|
at least one allows it |
|
|
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.