Repository navigation
Reduce repeated permission checks for relation aliases #325
Description
Checklist
- I agree to the terms within the OpenFGA Code of Conduct.
Describe the problem you'd like to have solved
Goal
Add an optional optimize_relation_aliases flag to BatchCheck and ListRelations, in both the synchronous and asynchronous Python clients. Keep it disabled by default.
An OpenFGA model defines permissions, called relations. A relation can simply refer to another relation: for example, can_edit can mean exactly the same thing as editor. This is an alias.
Applications can use several permission names for different actions even when they currently share the same rule. The SDK should avoid checking that rule repeatedly when the inputs are otherwise identical, while still returning a result for each permission the caller requested.
This combines the work proposed in #311 and #313.
Example
This model gives documents two directly assigned relations, viewer and editor. The other relations are aliases, including a chain from can_reply to editor:
model
schema 1.1
type user
type document
relations
define viewer: [user]
define editor: [user]
define can_view: viewer
define can_edit: editor
define can_comment: can_edit
define can_reply: can_comment
Assume Alice has both the viewer and editor relationships on document:42, and Bob has neither. These stored relationships are called tuples.
BatchCheck
BatchCheck answers several permission questions in one SDK call. A correlation_id is a caller-provided label used to match a response to its original check.
With an initialized fga_client and the model above stored under the given ID:
from openfga_sdk.client.models import (
ClientBatchCheckItem,
ClientBatchCheckRequest,
ClientListRelationsRequest,
)
options = {
"authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1",
"optimize_relation_aliases": True,
}
checks = [
ClientBatchCheckItem(
user="user:alice", relation="can_edit", object="document:42",
correlation_id="edit",
),
ClientBatchCheckItem(
user="user:alice", relation="can_comment", object="document:42",
correlation_id="comment",
),
ClientBatchCheckItem(
user="user:alice", relation="can_reply", object="document:42",
correlation_id="reply",
),
ClientBatchCheckItem(
user="user:bob", relation="can_reply", object="document:42",
correlation_id="bob-reply",
),
]
response = await fga_client.batch_check(
ClientBatchCheckRequest(checks=checks), options
)Alice's three checks become one check of editor. Bob's check stays separate because it has a different user. The response still contains four results:
| Correlation ID | Original relation | Allowed |
|---|---|---|
edit |
can_edit |
true |
comment |
can_comment |
true |
reply |
can_reply |
true |
bob-reply |
can_reply |
false |
Response order is not guaranteed; match results by correlation ID.
Add response.checks_sent to the existing response object without changing .result. It counts one item per optimized group plus each ungrouped check, across the whole call. Here it is 2, or 4 with optimization disabled. Retries and splitting the work into multiple HTTP requests do not change the count. Model reads are not counted. This lets callers see how many checks the optimization saved.
ListRelations
ListRelations returns the allowed permissions from a list supplied by the caller. Use BatchCheck internally so it can share the same optimization:
body = ClientListRelationsRequest(
user="user:alice",
object="document:42",
relations=["can_view", "can_edit", "can_comment", "can_reply"],
)
relations = await fga_client.list_relations(body, options)
# ["can_view", "can_edit", "can_comment", "can_reply"]Keep returning a plain list of the original relation names, in input order. Do not add checks_sent to this return value. If Alice had only viewer, the result would be ["can_view"]. An empty input list returns [] without a network request.
The BatchCheck-based path requires server support for BatchCheck. Document this compatibility requirement; the fallback below does not handle an unsupported endpoint.
Rules for combining checks
Use one model throughout the call
Use the existing model-ID resolution: a per-call authorization_model_id overrides the client's configured ID. A configured ID need not be repeated on every call. Optimization requires an ID from one of these sources.
Resolve the effective store and model ID once. Use those values for model loading, cache lookup, all checks, and any fallback, even if client configuration changes while the call is running.
The configuration holds an ID, not the model definition. Fetch the model when needed and cache its alias mappings by store and model ID. Models are immutable, but different stores or models must never share a cache entry.
Only follow simple aliases
Follow a relation that refers only to another relation on the same object, including chains such as can_reply -> can_comment -> can_edit -> editor. In the model API, this is a pure, same-object computed userset.
Do not simplify combined rules (or, and, or but not), relationships through another object (from, or tuple-to-userset), or object-qualified references. Missing targets and alias cycles are also ineligible. When the SDK cannot establish equivalence, send the original checks unchanged.
Compare every authorization input
Combine checks only when their final alias target, user, full object identifier (type and ID), context, and contextual tuples match.
- Context supplies condition inputs, such as
{"ViewCount": 100}for a condition requiringViewCount < 200. Numeric condition types includeint,uint(non-negative integers), anddouble(such as0.75). The declared parameter type determines which inputs are valid. - Contextual tuples are additional relationships supplied for that check. They may have conditions with their own names and context values.
Each BatchCheck item has its own context and contextual tuples. ListRelations supplies the same values to all checks it generates.
Compare the values as serialized for the API, preserving types, including inside nested objects and arrays. Python considers {"approved": True} equal to {"approved": 1}, but JSON distinguishes a boolean from a number. For a boolean condition, combining those checks could copy an allowed result onto an input that should produce a type error.
Include every contextual-tuple field, including condition names and contexts. Preserve array and tuple order and duplicates. Do not coerce, deduplicate, or otherwise normalize inputs unless equivalence is proven.
Leave userset-valued users unchanged
A request's user can identify a set of users rather than a person. For example, document:42#can_edit identifies the userset associated with that document's can_edit relation.
OpenFGA treats a userset as related to itself before following aliases. In the example model, these checks with user="document:42#can_edit", object="document:42", and no context or contextual tuples have different answers:
| Relation checked | Allowed | Reason |
|---|---|---|
can_edit |
true |
The subject is the exact userset being checked. |
editor |
false |
It is a different userset; editor: [user] provides no membership path for it. |
Replacing both with an editor check would incorrectly deny can_edit. See the self-defining check in the OpenFGA resolver.
For this implementation, do not group or rewrite checks whose user is a userset reference (object#relation). This applies to both methods. Ordinary-user checks may still be optimized when the model uses groups internally. Supporting userset-valued users needs separate evidence of equivalence and tests.
Results and errors
- Reject duplicate input correlation IDs before sending checks.
- Copy each returned group result to every original check in that group exactly once. Preserve the original request, relation name, correlation ID, allowed value, and error. Never assign a result outside its group or rely on response order.
- Do not turn an error or missing result into an allow or a normal denial. Keep per-item errors visible, and propagate request-level failures.
- Do not introduce logs or telemetry that expose users, objects, contexts, or contextual tuples.
ListRelations fallback
The current method raises on errors rather than returning a partial list. Preserve that behavior when adding the BatchCheck path:
- If BatchCheck returns all items without errors, return the allowed relation names in input order. A denied item is not an error and does not trigger fallback.
- If any item is missing or has a per-item error, rerun all original requested relations using individual checks, without alias optimization. Keep the original authorization inputs and options, including the resolved store and model ID.
- Return the allowed relations only if every fallback check completes without error. Otherwise raise the error; do not return a partial list or combine earlier batch results with fallback results.
Request-level BatchCheck exceptions, including model-loading and transport failures, propagate without triggering this fallback. Keep retries and fallback bounded and documented.
Acceptance criteria
Cover both sync and async clients:
- Optimization defaults to off. Existing response types stay unchanged apart from the additive BatchCheck
checks_sentproperty. - The four-check example returns all four original results and reports
checks_sent=2, including with retries or batch splitting; with optimization off, it reports4. - Allowed, denied, and error results reach every original item in the correct group exactly once, including when responses arrive out of order. Duplicate input IDs are rejected.
- Different users, objects, contexts, and contextual tuples stay separate. Cover condition names, tuple order and duplicates, and
Trueversus1andFalseversus0in nested objects, arrays, and tuple-condition contexts. Use a separate conditional model to verify that invalid inputs keep their errors; the introductory model has no conditions. - Missing alias targets, cycles, complex rules, and userset-valued users are not optimized. The userset example retains its distinct
trueandfalseBatchCheck results; ListRelations returns only["can_edit"]when asked for["can_edit", "editor"]. - Model IDs work from client configuration or per-call overrides. Alias caches stay isolated by store/model, and configuration changes during a call do not change its selected store or model.
- ListRelations preserves input order, makes no request for empty input, and follows each fallback and error path above with unchanged inputs and options.
- Documentation covers enabling the feature, model selection,
checks_sent, server compatibility, and fallback behavior.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIntake
Activity
[-]# Reduce repeated permission checks for relation aliases[/-][+]Reduce repeated permission checks for relation aliases[/+]