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 wardowl import AllOf, PermissionedRetriever, Subject, TenantAuthorizer
from wardowl.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=None) allows a chunk only when its metadata field
field holds the subject’s attribute attribute, which is the field’s own name when left as
None: TenantAuthorizer(field="org_id", attribute="org") compares the chunk’s org_id with the
subject’s org. Put the tenant id on every chunk at ingestion. A subject without the attribute, or
whose attribute is empty or not a string or integer, raises AuthorizationError: a request that
lost its tenant returns nothing, never everything.
One chunk can serve several tenants: store a list, {"tenant": ["acme", "globex"]}, and the chunk
matches the subjects of either, because In matches a list field by any of its elements, in the
check and in every backend’s filter. A subject belongs to one tenant at a time.
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 wardowl import AnyOf, MetadataACLAuthorizer
from wardowl.authorizers.opa import OPAAuthorizer
# Shared directly on the document, or allowed by policy.
authorizer = AnyOf(MetadataACLAuthorizer(), OPAAuthorizer(opa_url, filter_path="wardowl/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. It asks for
the scope whenever it post-filters, strategy="postfilter" included, so when every part has a
filter the store receives the same filter it would when pre-filtering
(What the store receives).
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 wardowl.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.