diff --git a/Makefile b/Makefile index d43f4847..94c27fef 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: sync test lint fmt fix check doc +.PHONY: sync test lint fmt fix check doc update-equivalent-checks-cases + +# openfga/language commit to copy the shared equivalent-checks cases from +LANGUAGE_REF ?= 5afcf707e44aa21d8ae17e47938b3f0164a13cc7 # Install/update dependencies sync: @@ -26,6 +29,11 @@ fix: uv run ruff check --fix . uv run ruff format . +# Copy the shared equivalent-checks cases from openfga/language +# Usage: make update-equivalent-checks-cases LANGUAGE_REF= +update-equivalent-checks-cases: + curl -fsSL https://raw.githubusercontent.com/openfga/language/$(LANGUAGE_REF)/tests/data/equivalent-checks-cases.json -o test/data/equivalent-checks-cases.json + # Run checks (lint + test) check: lint fmt test @@ -39,4 +47,5 @@ doc: @echo " fmt - Format code" @echo " fix - Fix fixable linting and formatting issues" @echo " check - Run both linting and tests" + @echo " update-equivalent-checks-cases - Copy the shared equivalent-checks cases from openfga/language (LANGUAGE_REF=)" @echo " doc - Show this help message" diff --git a/README.md b/README.md index fd19182b..146ceca0 100644 --- a/README.md +++ b/README.md @@ -965,6 +965,31 @@ response = await fga_client.batch_check(ClientBatchCheckRequest(checks=checks), # ] ``` +###### Merge equivalent checks (beta) + +Set `merge_equivalent_checks` to `True` to send checks that are certain to get the same answer only once. The SDK copies each answer back, so you still get one result per check you passed in. + +It merges: +- **Aliases:** with `define can_view: viewer`, checking both `viewer` and `can_view` for the same user and object sends one check. Chains like `define can_share: can_view` work too. +- **Exact repeats:** the same user, relation, object, context and contextual tuples. + +```python +options = { + "authorization_model_id": "01GXSA8YR785C4FYS3C0RTG7B1", # required, here or in the configuration + "merge_equivalent_checks": True, +} +response = await fga_client.batch_check(ClientBatchCheckRequest(checks=checks), options) +# response.checks_sent: how many checks this call actually sent +``` + +- **Off by default.** With it off, nothing changes. The value must be a bool, or the call fails with a validation error. +- **Needs an authorization model ID.** Without one, the call fails before anything is sent. +- **Reads each model once per client.** The SDK reads a model the first time it needs it and caches it. Create the client once and reuse it. +- **Falls back safely.** If the model can't be read (for example, no read access), the SDK logs a warning and sends the checks unmerged. +- **Errors are shared.** If a merged check returns an error, every check it stands for gets that error. +- **Correlation IDs** must match `^[\w\d-]{1,36}$` (ASCII). Missing ones are generated. +- **Telemetry:** see the `fga-client.batch_check.*` and `fga-client.model_cache.lookup` metrics in [OpenTelemetry](docs/opentelemetry.md). + ##### Client Batch Check diff --git a/docs/opentelemetry.md b/docs/opentelemetry.md index ce4ca6be..dfed134a 100644 --- a/docs/opentelemetry.md +++ b/docs/opentelemetry.md @@ -21,17 +21,22 @@ If you configure the OpenTelemetry SDK, these metrics will be exported and sent ### Supported Metrics -| Metric Name | Type | Enabled by Default | Description | -| -------------------------------- | --------- | ------------------ | --------------------------------------------------------------------------------- | -| `fga-client.request.duration` | Histogram | Yes | Total request time for FGA requests, in milliseconds | -| `fga-client.query.duration` | Histogram | Yes | Time taken by the FGA server to process and evaluate the request, in milliseconds | -| `fga-client.credentials.request` | Counter | Yes | Total number of new token requests initiated using the Client Credentials flow | -| `fga-client.request` | Counter | No | Total number of requests made to the FGA server | +| Metric Name | Type | Enabled by Default | Description | +| ----------------------------------------- | --------- | ------------------ | --------------------------------------------------------------------------------------------- | +| `fga-client.request.duration` | Histogram | Yes | Total request time for FGA requests, in milliseconds | +| `fga-client.query.duration` | Histogram | Yes | Time taken by the FGA server to process and evaluate the request, in milliseconds | +| `fga-client.credentials.request` | Counter | Yes | Total number of new token requests initiated using the Client Credentials flow | +| `fga-client.request` | Counter | No | Total number of requests made to the FGA server | +| `fga-client.batch_check.checks_requested` | Counter | Yes | Checks passed to `batch_check` with `merge_equivalent_checks` on | +| `fga-client.batch_check.checks_sent` | Counter | Yes | Checks actually sent by `batch_check` with `merge_equivalent_checks` on | +| `fga-client.batch_check.merge_fallback` | Counter | Yes | `batch_check` calls sent unmerged because the authorization model could not be read | +| `fga-client.model_cache.lookup` | Counter | Yes | Authorization model cache lookups by `merge_equivalent_checks`, with a `hit` or `miss` result | ### Supported Attributes | Attribute Name | Type | Enabled by Default | Description | | ------------------------------------- | ------ | ------------------ | --------------------------------------------------------------------------------- | +| `fga-client.model_cache.result` | string | Yes | Result of an authorization model cache lookup (`hit`/`miss`) | | `fga-client.request.batch_check_size` | int | No | The total size of the `check` list in a `BatchCheck` call | | `fga-client.request.client_id` | string | Yes | Client ID associated with the request, if any | | `fga-client.request.method` | string | Yes | FGA method/action that was performed (e.g., Check, ListObjects) in TitleCase | diff --git a/openfga_sdk/client/client.py b/openfga_sdk/client/client.py index b48a3ec8..9aee12d6 100644 --- a/openfga_sdk/client/client.py +++ b/openfga_sdk/client/client.py @@ -1,4 +1,5 @@ import asyncio +import logging import uuid from collections.abc import AsyncIterator @@ -7,6 +8,14 @@ from openfga_sdk.api.open_fga_api import OpenFgaApi from openfga_sdk.api_client import ApiClient from openfga_sdk.client.configuration import ClientConfiguration +from openfga_sdk.client.equivalent_checks import ( + expand, + merge_enabled, + resolve, + unmerged, + validate_correlation_ids, +) +from openfga_sdk.client.model_cache import AsyncModelCache from openfga_sdk.client.models.assertion import ClientAssertion from openfga_sdk.client.models.batch_check_item import ( ClientBatchCheckItem, @@ -71,9 +80,13 @@ WriteAuthorizationModelRequest, ) from openfga_sdk.models.write_request import WriteRequest +from openfga_sdk.telemetry.attributes import TelemetryAttributes from openfga_sdk.validation import is_well_formed_ulid_string +logger = logging.getLogger(__name__) + + def _chuck_array(array, max_size): """ Helper function to chuck array into arrays of max_size @@ -127,6 +140,16 @@ def options_to_kwargs( return kwargs +def _model_read_kwargs( + options: dict[str, int | str | dict[str, int | str]] | None = None, +) -> dict[str, int | str | dict[str, int | str]]: + """ + Return the kwargs a BatchCheck call passes on to its authorization model read + """ + kwargs = options_to_kwargs(options) + return {key: kwargs[key] for key in ("_headers", "_retry_params") if key in kwargs} + + def options_to_transaction_info( options: dict[str, int | str | dict[str, int | str]] | None = None, ): @@ -172,6 +195,7 @@ def __init__(self, configuration: ClientConfiguration): self._client_configuration = configuration self._api_client = ApiClient(configuration) self._api = OpenFgaApi(self._api_client) + self._model_cache = AsyncModelCache() # Set default headers from configuration if configuration.headers: @@ -185,6 +209,7 @@ async def __aexit__(self, exc_type, exc_value, traceback): await self.close() async def close(self): + await self._model_cache.close() await self._api.close() def _get_authorization_model_id( @@ -780,6 +805,7 @@ async def batch_check( :param authorization_model_id(options) - Overrides the authorization model id in the configuration :param max_parallel_requests(options) - Max number of requests to issue in parallel. Defaults to 10 :param max_batch_size(options) - Max number of checks to include in a request. Defaults to 50 + :param merge_equivalent_checks(options) - Beta. Send checks that are certain to get the same answer only once. Requires an authorization model id :param header(options) - Custom headers to send alongside the request :param retryParams(options) - Override the retry parameters for this request :param retryParams.maxRetry(options) - Override the max number of retries on each API request @@ -809,6 +835,11 @@ async def batch_check( elif isinstance(options["max_batch_size"], int): max_batch_size = options["max_batch_size"] + if merge_enabled(options): + return await self._merged_batch_check( + body, options, max_parallel_requests, max_batch_size + ) + id_to_check: dict[str, ClientBatchCheckItem] = {} def track_and_transform(checks): @@ -866,6 +897,116 @@ async def coro(checks): return ClientBatchCheckResponse(result) + async def _merged_batch_check( + self, + body: ClientBatchCheckRequest, + options: dict[str, int | str | dict[str, int | str]], + max_parallel_requests: int, + max_batch_size: int, + ) -> ClientBatchCheckResponse: + """ + Run a BatchCheck with merge_equivalent_checks on + """ + # Reading the model and sending the checks must use the same model. + authorization_model_id = self._get_authorization_model_id(options) + if authorization_model_id is None: + raise FgaValidationException( + "authorization_model_id is required when merge_equivalent_checks is on" + ) + store_id = self._api_client._get_store_id() + + checks = body.checks + for check in checks: + if check.correlation_id is None: + check.correlation_id = str(uuid.uuid4()) + validate_correlation_ids(checks) + + if not checks: + return ClientBatchCheckResponse([], checks_sent=0) + + telemetry = self._api_client._telemetry.metrics + telemetry_configuration = self._api_client.configuration.telemetry + attributes = { + TelemetryAttributes.fga_client_request_store_id: store_id, + TelemetryAttributes.fga_client_request_model_id: authorization_model_id, + } + + async def load_model(): + try: + response = await self._api.read_authorization_model( + authorization_model_id, **_model_read_kwargs(options) + ) + if response.authorization_model is None: + raise ValueError("The authorization model response is empty") + return response.authorization_model + except Exception as err: + logger.warning( + "merge_equivalent_checks: couldn't read authorization model %s in store %s (status: %s). " + "Sending checks unmerged.", + authorization_model_id, + store_id, + getattr(err, "status", None), + ) + raise + + lookup = await self._model_cache.get( + (store_id, authorization_model_id), load_model + ) + telemetry.modelCacheLookup( + attributes={ + **attributes, + TelemetryAttributes.fga_client_model_cache_result: ( + "hit" if lookup.hit else "miss" + ), + }, + configuration=telemetry_configuration, + ) + + if lookup.model is not None: + groups = resolve(lookup.model, checks) + else: + groups = unmerged(checks) + telemetry.batchCheckMergeFallback( + attributes={ + **attributes, + TelemetryAttributes.http_response_status_code: getattr( + lookup.error, "status", None + ), + }, + configuration=telemetry_configuration, + ) + + results = {} + sem = asyncio.Semaphore(max_parallel_requests) + consistency = self._get_consistency(options) + + async def send(chunk): + response = await self._single_batch_check( + BatchCheckRequest( + checks=[construct_batch_item(group.check) for group in chunk], + authorization_model_id=authorization_model_id, + consistency=consistency, + ), + sem, + options, + ) + results.update(response.result or {}) + + await asyncio.gather( + *[send(chunk) for chunk in _chuck_array(groups, max_batch_size)] + ) + + telemetry.batchCheckChecksRequested( + len(checks), attributes=attributes, configuration=telemetry_configuration + ) + telemetry.batchCheckChecksSent( + len(groups), attributes=attributes, configuration=telemetry_configuration + ) + + return ClientBatchCheckResponse( + expand(checks, groups, results), checks_sent=len(groups) + ) + async def expand( self, body: ClientExpandRequest, diff --git a/openfga_sdk/client/equivalent_checks.py b/openfga_sdk/client/equivalent_checks.py new file mode 100644 index 00000000..17fe9b2d --- /dev/null +++ b/openfga_sdk/client/equivalent_checks.py @@ -0,0 +1,363 @@ +""" +Merge equivalent checks in BatchCheck (the `merge_equivalent_checks` option). + +Checks that are certain to get the same answer are sent once, and the answer is +copied back to every original check. Rules: https://github.com/openfga/rfcs/pull/40 +""" + +import math +import re +import struct + +from collections import Counter +from dataclasses import dataclass + +from openfga_sdk.client.models.batch_check_item import ClientBatchCheckItem +from openfga_sdk.client.models.batch_check_single_response import ( + ClientBatchCheckSingleResponse, +) +from openfga_sdk.exceptions import FgaValidationException +from openfga_sdk.models.authorization_model import AuthorizationModel +from openfga_sdk.models.batch_check_single_result import BatchCheckSingleResult +from openfga_sdk.models.check_error import CheckError +from openfga_sdk.models.userset import Userset + + +CORRELATION_ID_PATTERN = re.compile(r"^[\w\d-]{1,36}$", re.ASCII) + +_MISSING = ("missing",) + + +class _Unmergeable(Exception): + """A value the SDK can't compare exactly, so the check is sent as is.""" + + +@dataclass +class EquivalentCheckGroup: + """ + A check to send, and the original checks it answers. + """ + + check: ClientBatchCheckItem + members: list[ClientBatchCheckItem] + + +def merge_enabled(options: dict | None) -> bool: + """ + Return whether the `merge_equivalent_checks` option is on. Raise if it isn't a bool, + so a value like "true" can't silently leave it off. + """ + value = (options or {}).get("merge_equivalent_checks") + if value is not None and not isinstance(value, bool): + raise FgaValidationException( + f"merge_equivalent_checks must be a bool, got {type(value).__name__}" + ) + return value is True + + +def validate_correlation_ids(checks: list[ClientBatchCheckItem]) -> None: + """ + Raise if a correlation ID doesn't match the API rule, or is used twice. + """ + seen: set[str] = set() + for check in checks: + correlation_id = check.correlation_id + # fullmatch, since `$` alone would also accept a trailing newline + if not isinstance(correlation_id, str) or not CORRELATION_ID_PATTERN.fullmatch( + correlation_id + ): + raise FgaValidationException( + f"correlation_id ({correlation_id}) is invalid: it must match {CORRELATION_ID_PATTERN.pattern}" + ) + if correlation_id in seen: + raise FgaValidationException( + f"Duplicate correlation_id ({correlation_id}) provided" + ) + seen.add(correlation_id) + + +def unmerged(checks: list[ClientBatchCheckItem]) -> list[EquivalentCheckGroup]: + """ + One group per check, sent as is. + """ + return [EquivalentCheckGroup(check=check, members=[check]) for check in checks] + + +def resolve( + model: AuthorizationModel, checks: list[ClientBatchCheckItem] +) -> list[EquivalentCheckGroup]: + """ + Group checks that are certain to get the same answer, and build the checks to send. + + The result is deterministic: the same model and checks, in the same order, give the + same groups and IDs. Each group is placed at the position of its first check. + """ + types = { + type_definition.type: type_definition.relations or {} + for type_definition in model.type_definitions or [] + } + + entries: list[tuple[str, ClientBatchCheckItem | tuple]] = [] + groups: dict[tuple, list[ClientBatchCheckItem]] = {} + finals: dict[tuple, str] = {} + + for check in checks: + final_relation = _final_relation(types, check) + key = None + if final_relation is not None: + try: + key = ( + final_relation, + check.user, + check.object, + _context_key(check.context), + _contextual_tuples_key(check.contextual_tuples), + ) + except _Unmergeable: + key = None + + if key is None: + entries.append(("as_is", check)) + continue + + if key not in groups: + groups[key] = [] + finals[key] = final_relation + entries.append(("group", key)) + groups[key].append(check) + + caller_ids = {check.correlation_id for check in checks} + next_number = 1 + result: list[EquivalentCheckGroup] = [] + + for kind, entry in entries: + if kind == "as_is": + result.append(EquivalentCheckGroup(check=entry, members=[entry])) + continue + + members = groups[entry] + first = members[0] + if len(members) == 1: + correlation_id = first.correlation_id + else: + while f"merged-{next_number}" in caller_ids: + next_number += 1 + correlation_id = f"merged-{next_number}" + next_number += 1 + + result.append( + EquivalentCheckGroup( + check=ClientBatchCheckItem( + user=first.user, + relation=finals[entry], + object=first.object, + correlation_id=correlation_id, + contextual_tuples=first.contextual_tuples, + context=first.context, + ), + members=members, + ) + ) + + return result + + +def expand( + checks: list[ClientBatchCheckItem], + groups: list[EquivalentCheckGroup], + results: dict[str, BatchCheckSingleResult], +) -> list[ClientBatchCheckSingleResponse]: + """ + Copy each group's answer to every check in the group, in the order of `checks`. + + A group with no answer gives an error for every check in it. Answers with unknown + correlation IDs are ignored. + """ + responses: dict[str, ClientBatchCheckSingleResponse] = {} + + for group in groups: + sent_id = group.check.correlation_id + result = results.get(sent_id) + allowed = result.allowed if result is not None else None + error = ( + result.error + if result is not None + else CheckError( + message=f"No result returned for correlation_id ({sent_id})" + ) + ) + + for member in group.members: + responses[member.correlation_id] = ClientBatchCheckSingleResponse( + allowed=allowed, + request=member, + correlation_id=member.correlation_id, + error=error, + ) + + return [responses[check.correlation_id] for check in checks] + + +def _alias_target(rewrite: Userset | None) -> str | None: + """ + Return the relation an alias points to, or None if the rewrite isn't a lone computedUserset. + """ + if rewrite is None or rewrite.computed_userset is None: + return None + + if ( + rewrite.this is not None + or rewrite.tuple_to_userset is not None + or rewrite.union is not None + or rewrite.intersection is not None + or rewrite.difference is not None + ): + return None + + target = rewrite.computed_userset + if target.object or not target.relation: + return None + + return target.relation + + +def _alias_chain(relations: dict[str, Userset], relation: str) -> list[str] | None: + """ + Follow aliases from `relation`. Return every relation on the way, ending at the final + relation, or None if the chain points to a missing relation or loops. + """ + chain = [relation] + while True: + if chain[-1] not in relations: + return None + + target = _alias_target(relations[chain[-1]]) + if target is None: + return chain + + if target in chain: + return None + + chain.append(target) + + +def _final_relation(types: dict[str, dict[str, Userset]], check) -> str | None: + """ + Return the relation to send the check with, or None to send it as is. + """ + if not all( + isinstance(value, str) for value in (check.user, check.relation, check.object) + ): + return None + + object_type, separator, object_id = check.object.partition(":") + if not separator or object_type not in types: + return None + + relations = types[object_type] + if check.relation not in relations: + return None + + user_type, separator, user_rest = check.user.partition(":") + if not separator or user_type not in types: + return None + + user_id, userset_separator, user_relation = user_rest.partition("#") + if userset_separator and user_relation not in types[user_type]: + return None + + chain = _alias_chain(relations, check.relation) + if chain is None: + return None + + # The server says a userset always has its own relation on its own object, at every + # step of a chain, so a merged check could get a different answer. + if ( + userset_separator + and (user_type, user_id) == (object_type, object_id) + and user_relation in chain + ): + return None + + # The server's answer can depend on which of these tuples it reads first. + tuple_keys = [ + (contextual_tuple.user, contextual_tuple.relation, contextual_tuple.object) + for contextual_tuple in check.contextual_tuples or [] + ] + if len(set(tuple_keys)) != len(tuple_keys): + return None + + return chain[-1] + + +def _context_key(context) -> tuple: + if context is None: + return _MISSING + + if not isinstance(context, dict): + raise _Unmergeable() + + return _canonical(context) + + +def _contextual_tuples_key(contextual_tuples) -> tuple | frozenset: + if contextual_tuples is None: + return _MISSING + + items = [] + for contextual_tuple in contextual_tuples: + condition = contextual_tuple.condition + condition_key = _MISSING + if condition is not None: + condition_key = (condition.name, _context_key(condition.context)) + + items.append( + ( + contextual_tuple.user, + contextual_tuple.relation, + contextual_tuple.object, + condition_key, + ) + ) + + return frozenset(Counter(items).items()) + + +def _canonical(value) -> tuple: + """ + A hashable form of a JSON value, equal only for values the server sees as equal. + Numbers compare as doubles, including the sign of zero. + """ + if value is None: + return ("null",) + + if isinstance(value, bool): + return ("bool", value) + + if isinstance(value, int | float): + try: + number = float(value) + except OverflowError as err: + raise _Unmergeable() from err + + if not math.isfinite(number): + raise _Unmergeable() + + return ("number", struct.pack(">d", number)) + + if isinstance(value, str): + return ("string", value) + + if isinstance(value, list): + return ("list", tuple(_canonical(item) for item in value)) + + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise _Unmergeable() + + return ( + "object", + frozenset((key, _canonical(item)) for key, item in value.items()), + ) + + raise _Unmergeable() diff --git a/openfga_sdk/client/model_cache.py b/openfga_sdk/client/model_cache.py new file mode 100644 index 00000000..1ade5549 --- /dev/null +++ b/openfga_sdk/client/model_cache.py @@ -0,0 +1,202 @@ +""" +In-memory cache of authorization models, used by BatchCheck's `merge_equivalent_checks` option. + +A model never changes once it's written, so a cached model never goes stale. Only +successful reads are cached. A failed read is remembered for a short time, so missing +read access doesn't add a read to every call. Calls for the same model share one +in-flight read. +""" + +import asyncio +import threading +import time + +from collections import OrderedDict +from collections.abc import Awaitable, Callable +from concurrent.futures import Future +from dataclasses import dataclass + +from openfga_sdk.models.authorization_model import AuthorizationModel + + +MAX_MODELS = 32 +FAILURE_TTL_SECONDS = 60.0 + +ModelCacheKey = tuple[str, str] + + +@dataclass +class ModelLookup: + """ + The result of a cache lookup: the model, or the error from reading it. + `hit` is True only when the model was already in the cache. + """ + + model: AuthorizationModel | None = None + hit: bool = False + error: Exception | None = None + + +class _ModelCacheState: + def __init__( + self, + max_models: int = MAX_MODELS, + failure_ttl: float = FAILURE_TTL_SECONDS, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._max_models = max_models + self._failure_ttl = failure_ttl + self._clock = clock + self._models: OrderedDict[ModelCacheKey, AuthorizationModel] = OrderedDict() + self._failures: OrderedDict[ModelCacheKey, tuple[float, Exception]] = ( + OrderedDict() + ) + + def _cached(self, key: ModelCacheKey) -> ModelLookup | None: + model = self._models.get(key) + if model is not None: + self._models.move_to_end(key) + return ModelLookup(model=model, hit=True) + + failure = self._failures.get(key) + if failure is not None: + expires_at, error = failure + if self._clock() < expires_at: + return ModelLookup(error=error) + del self._failures[key] + + return None + + def _store(self, key: ModelCacheKey, model: AuthorizationModel) -> None: + self._failures.pop(key, None) + self._models[key] = model + self._models.move_to_end(key) + while len(self._models) > self._max_models: + self._models.popitem(last=False) + + def _remember_failure(self, key: ModelCacheKey, error: Exception) -> None: + self._failures[key] = (self._clock() + self._failure_ttl, error) + self._failures.move_to_end(key) + while len(self._failures) > self._max_models: + self._failures.popitem(last=False) + + +class AsyncModelCache(_ModelCacheState): + """ + Model cache for the async client. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._loading: dict[ModelCacheKey, asyncio.Task] = {} + + async def get( + self, + key: ModelCacheKey, + load: Callable[[], Awaitable[AuthorizationModel]], + ) -> ModelLookup: + cached = self._cached(key) + if cached is not None: + return cached + + task = self._loading.get(key) + if task is None: + task = asyncio.ensure_future(self._load(key, load)) + task.add_done_callback(_retrieve_exception) + self._loading[key] = task + + # Shielded, so a cancelled caller doesn't cancel the read other callers share. + try: + return ModelLookup(model=await asyncio.shield(task)) + except asyncio.CancelledError: + raise + except Exception as error: + return ModelLookup(error=error) + + async def close(self) -> None: + """ + Cancel in-flight reads. + """ + tasks = list(self._loading.values()) + self._loading.clear() + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + async def _load( + self, + key: ModelCacheKey, + load: Callable[[], Awaitable[AuthorizationModel]], + ) -> AuthorizationModel: + try: + model = await load() + except Exception as error: + self._remember_failure(key, error) + raise + else: + self._store(key, model) + return model + finally: + if self._loading.get(key) is asyncio.current_task(): + del self._loading[key] + + +class ModelCache(_ModelCacheState): + """ + Model cache for the sync client. Safe to share across threads. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._lock = threading.Lock() + self._loading: dict[ModelCacheKey, Future] = {} + + def get( + self, + key: ModelCacheKey, + load: Callable[[], AuthorizationModel], + ) -> ModelLookup: + with self._lock: + cached = self._cached(key) + if cached is not None: + return cached + + future = self._loading.get(key) + owner = future is None + if owner: + future = Future() + self._loading[key] = future + + if not owner: + try: + return ModelLookup(model=future.result()) + except Exception as error: + return ModelLookup(error=error) + + try: + model = load() + except Exception as error: + with self._lock: + self._remember_failure(key, error) + self._loading.pop(key, None) + future.set_exception(error) + return ModelLookup(error=error) + except BaseException: + with self._lock: + self._loading.pop(key, None) + future.set_exception( + RuntimeError("Reading the authorization model was interrupted") + ) + raise + + with self._lock: + self._store(key, model) + self._loading.pop(key, None) + future.set_result(model) + return ModelLookup(model=model) + + +def _retrieve_exception(task: asyncio.Task) -> None: + # Marks the error as seen, so asyncio doesn't log it when no caller is left waiting. + if not task.cancelled(): + task.exception() diff --git a/openfga_sdk/client/models/batch_check_response.py b/openfga_sdk/client/models/batch_check_response.py index bef97aa3..decc654b 100644 --- a/openfga_sdk/client/models/batch_check_response.py +++ b/openfga_sdk/client/models/batch_check_response.py @@ -7,8 +7,10 @@ class ClientBatchCheckResponse: def __init__( self, result: list[ClientBatchCheckSingleResponse], + checks_sent: int | None = None, ) -> None: self._result = result + self._checks_sent = checks_sent @property def result(self) -> list[ClientBatchCheckSingleResponse]: @@ -23,3 +25,17 @@ def result(self, result: list[ClientBatchCheckSingleResponse]) -> None: Set result """ self._result = result + + @property + def checks_sent(self) -> int | None: + """ + Return how many checks this call sent. Set only when merge_equivalent_checks is on. + """ + return self._checks_sent + + @checks_sent.setter + def checks_sent(self, checks_sent: int | None) -> None: + """ + Set checks_sent + """ + self._checks_sent = checks_sent diff --git a/openfga_sdk/sync/client/client.py b/openfga_sdk/sync/client/client.py index c736daec..95e67a23 100644 --- a/openfga_sdk/sync/client/client.py +++ b/openfga_sdk/sync/client/client.py @@ -1,3 +1,4 @@ +import logging import uuid from collections.abc import Iterator @@ -5,6 +6,14 @@ from typing import Any from openfga_sdk.client.configuration import ClientConfiguration +from openfga_sdk.client.equivalent_checks import ( + expand, + merge_enabled, + resolve, + unmerged, + validate_correlation_ids, +) +from openfga_sdk.client.model_cache import ModelCache from openfga_sdk.client.models.assertion import ClientAssertion from openfga_sdk.client.models.batch_check_item import ( ClientBatchCheckItem, @@ -71,9 +80,13 @@ from openfga_sdk.models.write_request import WriteRequest from openfga_sdk.sync.api_client import ApiClient from openfga_sdk.sync.open_fga_api import OpenFgaApi +from openfga_sdk.telemetry.attributes import TelemetryAttributes from openfga_sdk.validation import is_well_formed_ulid_string +logger = logging.getLogger(__name__) + + def _chuck_array(array, max_size): """ Helper function to chuck array into arrays of max_size @@ -127,6 +140,16 @@ def options_to_kwargs( return kwargs +def _model_read_kwargs( + options: dict[str, int | str | dict[str, int | str]] | None = None, +) -> dict[str, int | str | dict[str, int | str]]: + """ + Return the kwargs a BatchCheck call passes on to its authorization model read + """ + kwargs = options_to_kwargs(options) + return {key: kwargs[key] for key in ("_headers", "_retry_params") if key in kwargs} + + def options_to_transaction_info( options: dict[str, int | str | dict[str, int | str]] | None = None, ): @@ -172,6 +195,7 @@ def __init__(self, configuration: ClientConfiguration) -> None: self._client_configuration = configuration self._api_client = ApiClient(configuration) self._api = OpenFgaApi(self._api_client) + self._model_cache = ModelCache() # Set default headers from configuration if configuration.headers: @@ -776,6 +800,7 @@ def batch_check( :param authorization_model_id(options) - Overrides the authorization model id in the configuration :param max_parallel_requests(options) - Max number of requests to issue in parallel. Defaults to 10 :param max_batch_size(options) - Max number of checks to include in a request. Defaults to 50 + :param merge_equivalent_checks(options) - Beta. Send checks that are certain to get the same answer only once. Requires an authorization model id :param header(options) - Custom headers to send alongside the request :param retryParams(options) - Override the retry parameters for this request :param retryParams.maxRetry(options) - Override the max number of retries on each API request @@ -805,6 +830,11 @@ def batch_check( elif isinstance(options["max_batch_size"], int): max_batch_size = options["max_batch_size"] + if merge_enabled(options): + return self._merged_batch_check( + body, options, max_parallel_requests, max_batch_size + ) + id_to_check: dict[str, ClientBatchCheckItem] = {} def track_and_transform(checks): @@ -864,6 +894,111 @@ def single_batch_check(checks): return ClientBatchCheckResponse(result) + def _merged_batch_check( + self, + body: ClientBatchCheckRequest, + options: dict[str, int | str | dict[str, int | str]], + max_parallel_requests: int, + max_batch_size: int, + ) -> ClientBatchCheckResponse: + """ + Run a BatchCheck with merge_equivalent_checks on + """ + # Reading the model and sending the checks must use the same model. + authorization_model_id = self._get_authorization_model_id(options) + if authorization_model_id is None: + raise FgaValidationException( + "authorization_model_id is required when merge_equivalent_checks is on" + ) + store_id = self._api_client._get_store_id() + + checks = body.checks + for check in checks: + if check.correlation_id is None: + check.correlation_id = str(uuid.uuid4()) + validate_correlation_ids(checks) + + if not checks: + return ClientBatchCheckResponse([], checks_sent=0) + + telemetry = self._api_client._telemetry.metrics + telemetry_configuration = self._api_client.configuration.telemetry + attributes = { + TelemetryAttributes.fga_client_request_store_id: store_id, + TelemetryAttributes.fga_client_request_model_id: authorization_model_id, + } + + def load_model(): + try: + response = self._api.read_authorization_model( + authorization_model_id, **_model_read_kwargs(options) + ) + if response.authorization_model is None: + raise ValueError("The authorization model response is empty") + return response.authorization_model + except Exception as err: + logger.warning( + "merge_equivalent_checks: couldn't read authorization model %s in store %s (status: %s). " + "Sending checks unmerged.", + authorization_model_id, + store_id, + getattr(err, "status", None), + ) + raise + + lookup = self._model_cache.get((store_id, authorization_model_id), load_model) + telemetry.modelCacheLookup( + attributes={ + **attributes, + TelemetryAttributes.fga_client_model_cache_result: ( + "hit" if lookup.hit else "miss" + ), + }, + configuration=telemetry_configuration, + ) + + if lookup.model is not None: + groups = resolve(lookup.model, checks) + else: + groups = unmerged(checks) + telemetry.batchCheckMergeFallback( + attributes={ + **attributes, + TelemetryAttributes.http_response_status_code: getattr( + lookup.error, "status", None + ), + }, + configuration=telemetry_configuration, + ) + + consistency = self._get_consistency(options) + + def send(chunk): + return self._single_batch_check( + BatchCheckRequest( + checks=[construct_batch_item(group.check) for group in chunk], + authorization_model_id=authorization_model_id, + consistency=consistency, + ), + options, + ) + + results = {} + with ThreadPoolExecutor(max_workers=max_parallel_requests) as executor: + for response in executor.map(send, _chuck_array(groups, max_batch_size)): + results.update(response.result or {}) + + telemetry.batchCheckChecksRequested( + len(checks), attributes=attributes, configuration=telemetry_configuration + ) + telemetry.batchCheckChecksSent( + len(groups), attributes=attributes, configuration=telemetry_configuration + ) + + return ClientBatchCheckResponse( + expand(checks, groups, results), checks_sent=len(groups) + ) + def expand( self, body: ClientExpandRequest, diff --git a/openfga_sdk/telemetry/attributes.py b/openfga_sdk/telemetry/attributes.py index 02451c1f..edb06b09 100644 --- a/openfga_sdk/telemetry/attributes.py +++ b/openfga_sdk/telemetry/attributes.py @@ -38,6 +38,9 @@ class TelemetryAttributes: fga_client_user: TelemetryAttribute = TelemetryAttribute( name="fga-client.user", ) + fga_client_model_cache_result: TelemetryAttribute = TelemetryAttribute( + name="fga-client.model_cache.result", + ) http_client_request_duration: TelemetryAttribute = TelemetryAttribute( name="http.client.request.duration", format="int", @@ -78,6 +81,7 @@ class TelemetryAttributes: fga_client_request_store_id, fga_client_response_model_id, fga_client_user, + fga_client_model_cache_result, http_client_request_duration, http_host, http_request_method, diff --git a/openfga_sdk/telemetry/configuration.py b/openfga_sdk/telemetry/configuration.py index 40958e88..b8922010 100644 --- a/openfga_sdk/telemetry/configuration.py +++ b/openfga_sdk/telemetry/configuration.py @@ -479,6 +479,7 @@ def clear(self) -> None: TelemetryAttributes.fga_client_request_store_id: False, TelemetryAttributes.fga_client_response_model_id: False, TelemetryAttributes.fga_client_user: False, + TelemetryAttributes.fga_client_model_cache_result: False, TelemetryAttributes.http_client_request_duration: False, TelemetryAttributes.http_host: False, TelemetryAttributes.http_request_method: False, @@ -616,6 +617,7 @@ def getSdkDefaults() -> dict[TelemetryAttribute | str, bool]: TelemetryAttributes.fga_client_request_store_id: True, TelemetryAttributes.fga_client_response_model_id: True, TelemetryAttributes.fga_client_user: False, + TelemetryAttributes.fga_client_model_cache_result: True, TelemetryAttributes.http_client_request_duration: False, TelemetryAttributes.http_host: True, TelemetryAttributes.http_request_method: True, @@ -828,6 +830,10 @@ def clear(self) -> None: TelemetryCounters.fga_client_credentials_request: None, TelemetryHistograms.fga_client_request_duration: None, TelemetryHistograms.fga_client_query_duration: None, + TelemetryCounters.fga_client_batch_check_checks_requested: None, + TelemetryCounters.fga_client_batch_check_checks_sent: None, + TelemetryCounters.fga_client_model_cache_lookup: None, + TelemetryCounters.fga_client_batch_check_merge_fallback: None, } self._valid = True @@ -990,6 +996,10 @@ def getSdkDefaults() -> dict[ TelemetryCounters.fga_client_credentials_request: TelemetryMetricConfiguration.getSdkDefaults(), TelemetryHistograms.fga_client_query_duration: TelemetryMetricConfiguration.getSdkDefaults(), TelemetryHistograms.fga_client_request_duration: TelemetryMetricConfiguration.getSdkDefaults(), + TelemetryCounters.fga_client_batch_check_checks_requested: TelemetryMetricConfiguration.getSdkDefaults(), + TelemetryCounters.fga_client_batch_check_checks_sent: TelemetryMetricConfiguration.getSdkDefaults(), + TelemetryCounters.fga_client_model_cache_lookup: TelemetryMetricConfiguration.getSdkDefaults(), + TelemetryCounters.fga_client_batch_check_merge_fallback: TelemetryMetricConfiguration.getSdkDefaults(), } diff --git a/openfga_sdk/telemetry/counters.py b/openfga_sdk/telemetry/counters.py index 7e2e0ae0..efc9d20e 100644 --- a/openfga_sdk/telemetry/counters.py +++ b/openfga_sdk/telemetry/counters.py @@ -18,9 +18,33 @@ class TelemetryCounters: description="Total number of requests made to the FGA server.", ) + fga_client_batch_check_checks_requested: TelemetryCounter = TelemetryCounter( + name="fga-client.batch_check.checks_requested", + description="Total number of checks passed to BatchCheck with merge_equivalent_checks on.", + ) + + fga_client_batch_check_checks_sent: TelemetryCounter = TelemetryCounter( + name="fga-client.batch_check.checks_sent", + description="Total number of checks sent by BatchCheck with merge_equivalent_checks on.", + ) + + fga_client_model_cache_lookup: TelemetryCounter = TelemetryCounter( + name="fga-client.model_cache.lookup", + description="Total number of authorization model cache lookups.", + ) + + fga_client_batch_check_merge_fallback: TelemetryCounter = TelemetryCounter( + name="fga-client.batch_check.merge_fallback", + description="Total number of BatchCheck calls sent unmerged because the authorization model couldn't be read.", + ) + _counters: list[TelemetryCounter] = [ fga_client_credentials_request, fga_client_request, + fga_client_batch_check_checks_requested, + fga_client_batch_check_checks_sent, + fga_client_model_cache_lookup, + fga_client_batch_check_merge_fallback, ] @staticmethod diff --git a/openfga_sdk/telemetry/metrics.py b/openfga_sdk/telemetry/metrics.py index 3cc9d739..8d8d1fe0 100644 --- a/openfga_sdk/telemetry/metrics.py +++ b/openfga_sdk/telemetry/metrics.py @@ -63,6 +63,101 @@ def histogram(self, histogram: TelemetryHistogram) -> Histogram: return self._histograms[histogram.name] + def _add( + self, + counter: TelemetryCounter, + value: int, + attributes: dict[TelemetryAttribute, str | bool | int | float] | None, + configuration: TelemetryConfiguration | None, + ) -> Counter: + instrument = self.counter(counter) + + if isMetricEnabled(configuration, counter): + attribute_filters = None + + if isinstance(configuration, TelemetryConfiguration) and isinstance( + configuration.metrics, TelemetryMetricsConfiguration + ): + metric_configuration = configuration.metrics.getMetrics( + filter_enabled=False + ).get(counter) + + if isinstance(metric_configuration, TelemetryMetricConfiguration): + attribute_filters = metric_configuration.getAttributes() + + prepared_attributes = TelemetryAttributes.prepare( + attributes, + filter=attribute_filters, + ) + + instrument.add(amount=value, attributes=prepared_attributes) + + return instrument + + def batchCheckChecksRequested( + self, + value: int, + attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, + configuration: TelemetryConfiguration | None = None, + ) -> Counter: + """ + Record the checks passed to BatchCheck with merge_equivalent_checks on. + """ + return self._add( + TelemetryCounters.fga_client_batch_check_checks_requested, + value, + attributes, + configuration, + ) + + def batchCheckChecksSent( + self, + value: int, + attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, + configuration: TelemetryConfiguration | None = None, + ) -> Counter: + """ + Record the checks sent by BatchCheck with merge_equivalent_checks on. + """ + return self._add( + TelemetryCounters.fga_client_batch_check_checks_sent, + value, + attributes, + configuration, + ) + + def modelCacheLookup( + self, + value: int = 1, + attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, + configuration: TelemetryConfiguration | None = None, + ) -> Counter: + """ + Record an authorization model cache lookup. + """ + return self._add( + TelemetryCounters.fga_client_model_cache_lookup, + value, + attributes, + configuration, + ) + + def batchCheckMergeFallback( + self, + value: int = 1, + attributes: dict[TelemetryAttribute, str | bool | int | float] | None = None, + configuration: TelemetryConfiguration | None = None, + ) -> Counter: + """ + Record a BatchCheck call sent unmerged because the authorization model couldn't be read. + """ + return self._add( + TelemetryCounters.fga_client_batch_check_merge_fallback, + value, + attributes, + configuration, + ) + def request( self, value: int = 1, diff --git a/test/client/batch_check_merge_test.py b/test/client/batch_check_merge_test.py new file mode 100644 index 00000000..ecf9e50a --- /dev/null +++ b/test/client/batch_check_merge_test.py @@ -0,0 +1,478 @@ +import asyncio +import json + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import patch + +import urllib3 + +from openfga_sdk import rest +from openfga_sdk.client import ClientConfiguration +from openfga_sdk.client.client import OpenFgaClient +from openfga_sdk.client.models.batch_check_item import ClientBatchCheckItem +from openfga_sdk.client.models.batch_check_request import ClientBatchCheckRequest +from openfga_sdk.client.models.batch_check_response import ClientBatchCheckResponse +from openfga_sdk.exceptions import ( + FgaValidationException, + ForbiddenException, + ValidationException, +) +from openfga_sdk.telemetry.attributes import TelemetryAttributes +from openfga_sdk.telemetry.metrics import TelemetryMetrics + + +STORE_ID = "01YCP46JKYM8FJCQ37NMBYHE5X" +MODEL_ID = "01GXSA8YR785C4FYS3C0RTG7B1" +API_URL = "http://api.fga.example" +MODEL_URL = f"{API_URL}/stores/{STORE_ID}/authorization-models/{MODEL_ID}" +BATCH_CHECK_URL = f"{API_URL}/stores/{STORE_ID}/batch-check" + +# define viewer: [user], can_view: viewer, can_share: can_view, editor: [user] +MODEL = { + "id": MODEL_ID, + "schema_version": "1.1", + "type_definitions": [ + {"type": "user"}, + { + "type": "document", + "relations": { + "viewer": {"this": {}}, + "can_view": {"computedUserset": {"relation": "viewer"}}, + "can_share": {"computedUserset": {"relation": "can_view"}}, + "editor": {"this": {}}, + }, + "metadata": { + "relations": { + "viewer": {"directly_related_user_types": [{"type": "user"}]}, + "can_view": {"directly_related_user_types": []}, + "can_share": {"directly_related_user_types": []}, + "editor": {"directly_related_user_types": [{"type": "user"}]}, + } + }, + }, + ], +} + + +def http_mock_response(body, status): + headers = urllib3.response.HTTPHeaderDict({"content-type": "application/json"}) + return urllib3.HTTPResponse( + body.encode("utf-8"), headers, status, preload_content=False + ) + + +def mock_response(body, status=200): + obj = http_mock_response(body, status) + return rest.RESTResponse(obj, obj.data) + + +def checks(): + return [ + ClientBatchCheckItem( + user="user:anne", relation="viewer", object="document:1", correlation_id="a" + ), + ClientBatchCheckItem( + user="user:anne", + relation="can_view", + object="document:1", + correlation_id="b", + ), + ClientBatchCheckItem( + user="user:anne", + relation="can_share", + object="document:1", + correlation_id="c", + ), + ClientBatchCheckItem( + user="user:anne", relation="editor", object="document:1", correlation_id="d" + ), + ] + + +class FakeServer: + """ + Answers model reads and BatchCheck requests, and records what was sent. + """ + + def __init__(self, model_error=None, answer=None, model_body=None): + self.model_error = model_error + self.model_body = ( + {"authorization_model": MODEL} if model_body is None else model_body + ) + self.answer = answer or ( + lambda check: {"allowed": check["tuple_key"]["relation"] == "viewer"} + ) + self.model_reads = 0 + self.model_read_headers = [] + self.sent = [] + + def __call__(self, method, url, **kwargs): + if method == "GET" and url == MODEL_URL: + self.model_reads += 1 + self.model_read_headers.append(kwargs.get("headers") or {}) + if self.model_error is not None: + raise self.model_error + return mock_response(json.dumps(self.model_body)) + + if method == "POST" and url == BATCH_CHECK_URL: + body = kwargs["body"] + self.sent.append(body) + return mock_response( + json.dumps( + { + "result": { + check["correlation_id"]: self.answer(check) + for check in body["checks"] + } + } + ) + ) + + raise AssertionError(f"Unexpected request: {method} {url}") + + +def sent_checks(server): + return [ + ( + check["correlation_id"], + check["tuple_key"]["relation"], + ) + for body in server.sent + for check in body["checks"] + ] + + +def forbidden(): + return ForbiddenException(http_resp=http_mock_response("{}", 403)) + + +class TestBatchCheckMergeEquivalentChecks(IsolatedAsyncioTestCase): + def setUp(self): + self.configuration = ClientConfiguration( + api_url=API_URL, + store_id=STORE_ID, + authorization_model_id=MODEL_ID, + ) + + async def batch_check(self, client, items, options=None): + return await client.batch_check( + ClientBatchCheckRequest(checks=items), + options={"merge_equivalent_checks": True, **(options or {})}, + ) + + @patch.object(rest.RESTClientObject, "request") + async def test_merges_aliases_and_copies_the_answer_back(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + items = checks() + + async with OpenFgaClient(self.configuration) as client: + response = await self.batch_check(client, items) + + self.assertEqual(server.model_reads, 1) + self.assertEqual(sent_checks(server), [("merged-1", "viewer"), ("d", "editor")]) + self.assertEqual(server.sent[0]["authorization_model_id"], MODEL_ID) + self.assertEqual(response.checks_sent, 2) + self.assertEqual( + [(r.correlation_id, r.allowed, r.error) for r in response.result], + [ + ("a", True, None), + ("b", True, None), + ("c", True, None), + ("d", False, None), + ], + ) + self.assertEqual([r.request for r in response.result], items) + + @patch.object(rest.RESTClientObject, "request") + async def test_merges_checks_that_would_land_in_different_batches( + self, mock_request + ): + server = FakeServer() + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + response = await self.batch_check( + client, checks()[:3], options={"max_batch_size": 1} + ) + + self.assertEqual(sent_checks(server), [("merged-1", "viewer")]) + self.assertEqual(response.checks_sent, 1) + self.assertEqual(len(response.result), 3) + + @patch.object(rest.RESTClientObject, "request") + async def test_reads_the_model_once_for_parallel_and_later_calls( + self, mock_request + ): + server = FakeServer() + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + await asyncio.gather( + *[self.batch_check(client, checks()) for _ in range(5)] + ) + await self.batch_check(client, checks()) + + self.assertEqual(server.model_reads, 1) + self.assertEqual(len(server.sent), 6) + + @patch.object(rest.RESTClientObject, "request") + async def test_requires_a_model_id(self, mock_request): + self.configuration.authorization_model_id = None + + async with OpenFgaClient(self.configuration) as client: + with self.assertRaises(FgaValidationException): + await self.batch_check(client, checks()) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + async def test_rejects_an_option_value_that_is_not_a_bool(self, mock_request): + async with OpenFgaClient(self.configuration) as client: + with self.assertRaisesRegex( + FgaValidationException, "merge_equivalent_checks must be a bool" + ): + await self.batch_check( + client, checks(), options={"merge_equivalent_checks": "true"} + ) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + async def test_reads_the_model_with_the_callers_headers(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + await self.batch_check( + client, checks(), options={"headers": {"X-Custom": "1"}} + ) + + self.assertEqual(server.model_read_headers[0].get("X-Custom"), "1") + + @patch.object(rest.RESTClientObject, "request") + async def test_rejects_invalid_correlation_ids(self, mock_request): + for correlation_id in ["has space", "x" * 37, "é", "a\n"]: + items = checks() + items[0].correlation_id = correlation_id + + async with OpenFgaClient(self.configuration) as client: + with self.assertRaises(FgaValidationException): + await self.batch_check(client, items) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + async def test_rejects_duplicate_correlation_ids(self, mock_request): + items = checks() + items[1].correlation_id = "a" + + async with OpenFgaClient(self.configuration) as client: + with self.assertRaisesRegex( + FgaValidationException, r"Duplicate correlation_id \(a\) provided" + ): + await self.batch_check(client, items) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + async def test_generates_missing_correlation_ids(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + items = checks() + items[3].correlation_id = None + + async with OpenFgaClient(self.configuration) as client: + response = await self.batch_check(client, items) + + self.assertIsNotNone(items[3].correlation_id) + self.assertEqual(response.result[3].correlation_id, items[3].correlation_id) + + def test_checks_sent_defaults_to_none_and_can_be_set(self): + response = ClientBatchCheckResponse([]) + self.assertIsNone(response.checks_sent) + + response.checks_sent = 3 + + self.assertEqual(response.checks_sent, 3) + + @patch.object(rest.RESTClientObject, "request") + async def test_no_checks_sends_nothing(self, mock_request): + async with OpenFgaClient(self.configuration) as client: + response = await self.batch_check(client, []) + + self.assertEqual(response.result, []) + self.assertEqual(response.checks_sent, 0) + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + async def test_sends_unmerged_when_the_model_cannot_be_read(self, mock_request): + server = FakeServer(model_error=forbidden()) + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + with self.assertLogs("openfga_sdk.client.client", "WARNING") as logs: + response = await self.batch_check(client, checks()) + second = await self.batch_check(client, checks()) + + self.assertEqual(len(logs.records), 1) + self.assertIn(MODEL_ID, logs.output[0]) + self.assertIn(STORE_ID, logs.output[0]) + self.assertIn("403", logs.output[0]) + self.assertEqual(server.model_reads, 1) + unmerged = [ + ("a", "viewer"), + ("b", "can_view"), + ("c", "can_share"), + ("d", "editor"), + ] + self.assertEqual(sent_checks(server), unmerged * 2) + self.assertEqual(response.checks_sent, 4) + self.assertEqual(second.checks_sent, 4) + self.assertEqual( + [r.allowed for r in response.result], [True, False, False, False] + ) + + @patch.object(rest.RESTClientObject, "request") + async def test_sends_unmerged_when_the_model_response_is_empty(self, mock_request): + server = FakeServer(model_body={}) + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + with self.assertLogs("openfga_sdk.client.client", "WARNING") as logs: + response = await self.batch_check(client, checks()) + + self.assertEqual(len(logs.records), 1) + self.assertEqual( + sent_checks(server), + [("a", "viewer"), ("b", "can_view"), ("c", "can_share"), ("d", "editor")], + ) + self.assertEqual(response.checks_sent, 4) + + @patch.object(rest.RESTClientObject, "request") + async def test_copies_an_error_to_every_check_in_the_group(self, mock_request): + server = FakeServer( + answer=lambda check: ( + {"error": {"message": "boom"}} + if check["correlation_id"] == "merged-1" + else {"allowed": True} + ) + ) + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + response = await self.batch_check(client, checks()) + + for result in response.result[:3]: + self.assertFalse(result.allowed) + self.assertEqual(result.error.message, "boom") + self.assertTrue(response.result[3].allowed) + self.assertIsNone(response.result[3].error) + + @patch.object(rest.RESTClientObject, "request") + async def test_a_missing_answer_is_an_error_and_unknown_answers_are_ignored( + self, mock_request + ): + def handle(method, url, **kwargs): + if method == "GET": + return mock_response(json.dumps({"authorization_model": MODEL})) + return mock_response( + json.dumps( + {"result": {"d": {"allowed": True}, "unknown": {"allowed": True}}} + ) + ) + + mock_request.side_effect = handle + + async with OpenFgaClient(self.configuration) as client: + response = await self.batch_check(client, checks()) + + self.assertEqual( + [r.correlation_id for r in response.result], ["a", "b", "c", "d"] + ) + for result in response.result[:3]: + self.assertFalse(result.allowed) + self.assertEqual( + result.error.message, "No result returned for correlation_id (merged-1)" + ) + self.assertTrue(response.result[3].allowed) + + @patch.object(rest.RESTClientObject, "request") + async def test_a_failed_request_fails_the_call(self, mock_request): + def handle(method, url, **kwargs): + if method == "GET": + return mock_response(json.dumps({"authorization_model": MODEL})) + raise ValidationException(http_resp=http_mock_response("{}", 400)) + + mock_request.side_effect = handle + + async with OpenFgaClient(self.configuration) as client: + with self.assertRaises(ValidationException): + await self.batch_check(client, checks()) + + @patch.object(rest.RESTClientObject, "request") + async def test_option_off_is_unchanged(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + + async with OpenFgaClient(self.configuration) as client: + response = await client.batch_check( + ClientBatchCheckRequest(checks=checks()) + ) + + self.assertEqual(server.model_reads, 0) + self.assertEqual( + sent_checks(server), + [("a", "viewer"), ("b", "can_view"), ("c", "can_share"), ("d", "editor")], + ) + self.assertIsNone(response.checks_sent) + + @patch.object(TelemetryMetrics, "batchCheckChecksSent") + @patch.object(TelemetryMetrics, "batchCheckChecksRequested") + @patch.object(TelemetryMetrics, "batchCheckMergeFallback") + @patch.object(TelemetryMetrics, "modelCacheLookup") + @patch.object(rest.RESTClientObject, "request") + async def test_records_telemetry( + self, mock_request, mock_lookup, mock_fallback, mock_requested, mock_sent + ): + mock_request.side_effect = FakeServer() + attributes = { + TelemetryAttributes.fga_client_request_store_id: STORE_ID, + TelemetryAttributes.fga_client_request_model_id: MODEL_ID, + } + + async with OpenFgaClient(self.configuration) as client: + await self.batch_check(client, checks()) + await self.batch_check(client, checks()) + + self.assertEqual( + [ + call.kwargs["attributes"][ + TelemetryAttributes.fga_client_model_cache_result + ] + for call in mock_lookup.call_args_list + ], + ["miss", "hit"], + ) + mock_fallback.assert_not_called() + self.assertEqual(mock_requested.call_args.args, (4,)) + self.assertEqual(mock_requested.call_args.kwargs["attributes"], attributes) + self.assertEqual(mock_sent.call_args.args, (2,)) + self.assertEqual(mock_sent.call_args.kwargs["attributes"], attributes) + + @patch.object(TelemetryMetrics, "batchCheckMergeFallback") + @patch.object(rest.RESTClientObject, "request") + async def test_records_the_fallback_with_its_status_code( + self, mock_request, mock_fallback + ): + mock_request.side_effect = FakeServer(model_error=forbidden()) + + async with OpenFgaClient(self.configuration) as client: + with self.assertLogs("openfga_sdk.client.client", "WARNING"): + await self.batch_check(client, checks()) + + attributes = mock_fallback.call_args.kwargs["attributes"] + self.assertEqual(attributes[TelemetryAttributes.http_response_status_code], 403) + self.assertEqual( + attributes[TelemetryAttributes.fga_client_request_model_id], MODEL_ID + ) diff --git a/test/client/equivalent_checks_cases_test.py b/test/client/equivalent_checks_cases_test.py new file mode 100644 index 00000000..2897e166 --- /dev/null +++ b/test/client/equivalent_checks_cases_test.py @@ -0,0 +1,92 @@ +""" +Runs the shared equivalent-checks cases from openfga/language against the Python resolver. + +The cases file is copied from openfga/language. Run `make update-equivalent-checks-cases` +to update it. +""" + +import json + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from openfga_sdk.client.equivalent_checks import resolve +from openfga_sdk.client.models.batch_check_item import ClientBatchCheckItem +from openfga_sdk.client.models.tuple import ClientTuple +from openfga_sdk.models.relationship_condition import RelationshipCondition +from openfga_sdk.sync.api_client import ApiClient + + +CASES_FILE = Path(__file__).parent.parent / "data" / "equivalent-checks-cases.json" +CASES = json.loads(CASES_FILE.read_text())["cases"] +MODEL_ID = "01GXSA8YR785C4FYS3C0RTG7B1" + + +def _model(model_json): + with ApiClient() as api_client: + return api_client.deserialize( + SimpleNamespace(data=json.dumps(dict(model_json, id=MODEL_ID))), + "AuthorizationModel", + ) + + +def _contextual_tuple(tuple_json): + condition = tuple_json.get("condition") + return ClientTuple( + user=tuple_json["user"], + relation=tuple_json["relation"], + object=tuple_json["object"], + condition=( + RelationshipCondition( + name=condition["name"], context=condition.get("context") + ) + if condition is not None + else None + ), + ) + + +def _check(check_json): + contextual_tuples = check_json.get("contextual_tuples") + return ClientBatchCheckItem( + user=check_json["user"], + relation=check_json["relation"], + object=check_json["object"], + correlation_id=check_json["id"], + context=check_json.get("context"), + contextual_tuples=( + [_contextual_tuple(item) for item in contextual_tuples] + if contextual_tuples is not None + else None + ), + ) + + +def test_cases_file_is_not_empty(): + assert len(CASES) > 0 + + +@pytest.mark.parametrize("case", CASES, ids=[case["name"] for case in CASES]) +def test_equivalent_checks_case(case): + checks = [_check(check) for check in case["checks"]] + + groups = resolve(_model(case["model"]), checks) + + actual = [ + { + "id": group.check.correlation_id, + "user": group.check.user, + "relation": group.check.relation, + "object": group.check.object, + "for": [member.correlation_id for member in group.members], + } + for group in groups + ] + assert actual == case["expected_sent"] + + for group in groups: + first = group.members[0] + assert group.check.context is first.context + assert group.check.contextual_tuples is first.contextual_tuples diff --git a/test/client/equivalent_checks_test.py b/test/client/equivalent_checks_test.py new file mode 100644 index 00000000..7d6b9587 --- /dev/null +++ b/test/client/equivalent_checks_test.py @@ -0,0 +1,252 @@ +import datetime +import math + +import pytest + +from openfga_sdk.client.equivalent_checks import ( + EquivalentCheckGroup, + expand, + merge_enabled, + resolve, + unmerged, + validate_correlation_ids, +) +from openfga_sdk.client.models.batch_check_item import ClientBatchCheckItem +from openfga_sdk.client.models.tuple import ClientTuple +from openfga_sdk.exceptions import FgaValidationException +from openfga_sdk.models.authorization_model import AuthorizationModel +from openfga_sdk.models.batch_check_single_result import BatchCheckSingleResult +from openfga_sdk.models.check_error import CheckError +from openfga_sdk.models.object_relation import ObjectRelation +from openfga_sdk.models.relationship_condition import RelationshipCondition +from openfga_sdk.models.type_definition import TypeDefinition +from openfga_sdk.models.userset import Userset + + +# define viewer: [user], can_view: viewer +MODEL = AuthorizationModel( + id="01GXSA8YR785C4FYS3C0RTG7B1", + schema_version="1.1", + type_definitions=[ + TypeDefinition(type="user"), + TypeDefinition( + type="document", + relations={ + "viewer": Userset(this={}), + "can_view": Userset( + computed_userset=ObjectRelation(object="", relation="viewer") + ), + }, + ), + ], +) + + +def check(correlation_id, relation="can_view", context=None, contextual_tuples=None): + return ClientBatchCheckItem( + user="user:anne", + relation=relation, + object="document:1", + correlation_id=correlation_id, + context=context, + contextual_tuples=contextual_tuples, + ) + + +def sent(groups): + return [ + ( + group.check.correlation_id, + group.check.relation, + [member.correlation_id for member in group.members], + ) + for group in groups + ] + + +@pytest.mark.parametrize( + "value", + [ + math.nan, + math.inf, + 10**400, + (1, 2), + datetime.datetime(2026, 1, 1), + {1: "non-string key"}, + {"nested": [object()]}, + ], + ids=["nan", "inf", "huge-int", "tuple", "datetime", "int-key", "object"], +) +def test_values_that_cannot_be_compared_are_sent_as_is(value): + groups = resolve( + MODEL, + [check("a", context={"x": value}), check("b", context={"x": value})], + ) + + assert sent(groups) == [("a", "can_view", ["a"]), ("b", "can_view", ["b"])] + + +def test_a_context_that_is_not_an_object_is_sent_as_is(): + groups = resolve(MODEL, [check("a", context=["x"]), check("b", context=["x"])]) + + assert sent(groups) == [("a", "can_view", ["a"]), ("b", "can_view", ["b"])] + + +def test_values_in_a_condition_context_that_cannot_be_compared_are_sent_as_is(): + contextual_tuples = [ + ClientTuple( + user="user:anne", + relation="viewer", + object="document:2", + condition=RelationshipCondition(name="c", context={"x": math.nan}), + ) + ] + + groups = resolve( + MODEL, + [ + check("a", contextual_tuples=contextual_tuples), + check("b", contextual_tuples=contextual_tuples), + ], + ) + + assert sent(groups) == [("a", "can_view", ["a"]), ("b", "can_view", ["b"])] + + +def test_checks_that_are_not_strings_are_sent_as_is(): + item = check("a") + item.user = None + + assert sent(resolve(MODEL, [item])) == [("a", "can_view", ["a"])] + + +def test_a_model_without_type_definitions_sends_everything_as_is(): + model = AuthorizationModel( + id="01GXSA8YR785C4FYS3C0RTG7B1", schema_version="1.1", type_definitions=[] + ) + + assert sent(resolve(model, [check("a"), check("b")])) == [ + ("a", "can_view", ["a"]), + ("b", "can_view", ["b"]), + ] + + +def test_unmerged_sends_every_check_as_is(): + items = [check("a"), check("b")] + + assert sent(unmerged(items)) == [ + ("a", "can_view", ["a"]), + ("b", "can_view", ["b"]), + ] + + +@pytest.mark.parametrize( + "correlation_id", + ["", "has space", "x" * 37, "é", "a/b", "a\n", 1], +) +def test_validate_correlation_ids_rejects_invalid_ids(correlation_id): + with pytest.raises(FgaValidationException, match="is invalid"): + validate_correlation_ids([check(correlation_id)]) + + +@pytest.mark.parametrize( + "options, enabled", + [ + (None, False), + ({}, False), + ({"merge_equivalent_checks": None}, False), + ({"merge_equivalent_checks": False}, False), + ({"merge_equivalent_checks": True}, True), + ], +) +def test_merge_enabled(options, enabled): + assert merge_enabled(options) is enabled + + +@pytest.mark.parametrize("value", ["true", 1, "yes"]) +def test_merge_enabled_rejects_values_that_are_not_bools(value): + with pytest.raises( + FgaValidationException, match="merge_equivalent_checks must be a bool" + ): + merge_enabled({"merge_equivalent_checks": value}) + + +def test_validate_correlation_ids_accepts_valid_ids(): + validate_correlation_ids([check("a_b-1"), check("x" * 36)]) + + +def test_validate_correlation_ids_rejects_duplicates(): + with pytest.raises( + FgaValidationException, match=r"Duplicate correlation_id \(a\) provided" + ): + validate_correlation_ids([check("a"), check("a")]) + + +def test_expand_copies_answers_in_input_order(): + a, b, c = check("a"), check("b"), check("c", relation="viewer") + groups = [ + EquivalentCheckGroup(check=check("c", relation="viewer"), members=[c]), + EquivalentCheckGroup( + check=check("merged-1", relation="viewer"), members=[a, b] + ), + ] + error = CheckError(message="boom") + + responses = expand( + [a, b, c], + groups, + { + "merged-1": BatchCheckSingleResult(error=error), + "c": BatchCheckSingleResult(allowed=True), + "unknown": BatchCheckSingleResult(allowed=True), + }, + ) + + assert [(r.correlation_id, r.allowed, r.error) for r in responses] == [ + ("a", False, error), + ("b", False, error), + ("c", True, None), + ] + assert [r.request for r in responses] == [a, b, c] + + +def test_expand_turns_a_missing_answer_into_an_error(): + a, b = check("a"), check("b") + + responses = expand( + [a, b], + [EquivalentCheckGroup(check=check("merged-1"), members=[a, b])], + {}, + ) + + for response in responses: + assert response.allowed is False + assert response.error.message == ( + "No result returned for correlation_id (merged-1)" + ) + + +@pytest.mark.parametrize( + "can_view", + [ + Userset(computed_userset=ObjectRelation(object="parent", relation="viewer")), + Userset(this={}, computed_userset=ObjectRelation(object="", relation="viewer")), + ], + ids=["object-set", "mixed-rewrite"], +) +def test_rewrites_that_only_look_like_aliases_are_not_merged(can_view): + model = AuthorizationModel( + id="01GXSA8YR785C4FYS3C0RTG7B1", + schema_version="1.1", + type_definitions=[ + TypeDefinition(type="user"), + TypeDefinition( + type="document", + relations={"viewer": Userset(this={}), "can_view": can_view}, + ), + ], + ) + + groups = resolve(model, [check("a", relation="viewer"), check("b")]) + + assert sent(groups) == [("a", "viewer", ["a"]), ("b", "can_view", ["b"])] diff --git a/test/client/model_cache_test.py b/test/client/model_cache_test.py new file mode 100644 index 00000000..b30a4798 --- /dev/null +++ b/test/client/model_cache_test.py @@ -0,0 +1,248 @@ +import asyncio +import threading +import time + +from unittest import IsolatedAsyncioTestCase, TestCase + +from openfga_sdk.client.model_cache import AsyncModelCache, ModelCache + + +KEY = ("store", "model") + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +class TestAsyncModelCache(IsolatedAsyncioTestCase): + async def test_parallel_lookups_share_one_read_and_later_lookups_hit(self): + cache = AsyncModelCache() + release = asyncio.Event() + reads = 0 + + async def load(): + nonlocal reads + reads += 1 + await release.wait() + return "model" + + lookups = [asyncio.ensure_future(cache.get(KEY, load)) for _ in range(3)] + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*lookups) + + self.assertEqual(reads, 1) + self.assertEqual([(r.model, r.hit) for r in results], [("model", False)] * 3) + + later = await cache.get(KEY, load) + self.assertEqual((later.model, later.hit, reads), ("model", True, 1)) + + async def test_a_failed_read_is_remembered_for_a_short_time(self): + clock = FakeClock() + cache = AsyncModelCache(failure_ttl=60, clock=clock) + reads = 0 + error = RuntimeError("forbidden") + + async def load(): + nonlocal reads + reads += 1 + raise error + + first = await cache.get(KEY, load) + clock.now = 59 + second = await cache.get(KEY, load) + + self.assertIs(first.error, error) + self.assertIs(second.error, error) + self.assertIsNone(second.model) + self.assertEqual(reads, 1) + + clock.now = 60 + await cache.get(KEY, load) + self.assertEqual(reads, 2) + + async def test_drops_the_least_recently_used_model(self): + cache = AsyncModelCache(max_models=2) + + async def load_for(key): + async def load(): + return f"model-{key}" + + return await cache.get(key, load) + + await load_for("a") + await load_for("b") + self.assertTrue((await load_for("a")).hit) + await load_for("c") + + self.assertTrue((await load_for("a")).hit) + self.assertFalse((await load_for("b")).hit) + + async def test_a_cancelled_caller_does_not_cancel_the_shared_read(self): + cache = AsyncModelCache() + release = asyncio.Event() + + async def load(): + await release.wait() + return "model" + + cancelled = asyncio.ensure_future(cache.get(KEY, load)) + waiting = asyncio.ensure_future(cache.get(KEY, load)) + await asyncio.sleep(0) + cancelled.cancel() + release.set() + + self.assertEqual((await waiting).model, "model") + with self.assertRaises(asyncio.CancelledError): + await cancelled + + async def test_close_cancels_in_flight_reads_without_caching_them(self): + cache = AsyncModelCache() + started = asyncio.Event() + + async def hang(): + started.set() + await asyncio.Event().wait() + + lookup = asyncio.ensure_future(cache.get(KEY, hang)) + await started.wait() + await cache.close() + + with self.assertRaises(asyncio.CancelledError): + await lookup + + async def load(): + return "model" + + self.assertEqual((await cache.get(KEY, load)).model, "model") + + +class TestModelCache(TestCase): + def test_threads_share_one_read_and_later_lookups_hit(self): + cache = ModelCache() + started = threading.Event() + release = threading.Event() + reads = 0 + + def load(): + nonlocal reads + reads += 1 + started.set() + release.wait() + return "model" + + results = [] + owner = threading.Thread(target=lambda: results.append(cache.get(KEY, load))) + owner.start() + started.wait() + waiters = [ + threading.Thread(target=lambda: results.append(cache.get(KEY, load))) + for _ in range(3) + ] + for waiter in waiters: + waiter.start() + release.set() + for thread in [owner, *waiters]: + thread.join() + + self.assertEqual(reads, 1) + self.assertEqual([r.model for r in results], ["model"] * 4) + self.assertTrue(cache.get(KEY, load).hit) + + def test_a_failed_read_is_remembered_for_a_short_time(self): + clock = FakeClock() + cache = ModelCache(failure_ttl=60, clock=clock) + reads = 0 + error = RuntimeError("forbidden") + + def load(): + nonlocal reads + reads += 1 + raise error + + self.assertIs(cache.get(KEY, load).error, error) + clock.now = 59 + self.assertIs(cache.get(KEY, load).error, error) + self.assertEqual(reads, 1) + + clock.now = 60 + cache.get(KEY, load) + self.assertEqual(reads, 2) + + def test_drops_the_least_recently_used_model(self): + cache = ModelCache(max_models=2) + + def get(key): + return cache.get(key, lambda: f"model-{key}") + + get("a") + get("b") + self.assertTrue(get("a").hit) + get("c") + + self.assertTrue(get("a").hit) + self.assertFalse(get("b").hit) + + def test_threads_waiting_on_a_failed_read_get_its_error(self): + cache = ModelCache() + started = threading.Event() + release = threading.Event() + error = RuntimeError("forbidden") + + def load(): + started.set() + release.wait() + raise error + + results = [] + owner = threading.Thread(target=lambda: results.append(cache.get(KEY, load))) + owner.start() + started.wait() + waiters = [ + threading.Thread(target=lambda: results.append(cache.get(KEY, load))) + for _ in range(3) + ] + for waiter in waiters: + waiter.start() + time.sleep(0.05) + release.set() + for thread in [owner, *waiters]: + thread.join() + + self.assertEqual([r.error for r in results], [error] * 4) + + def test_remembers_a_bounded_number_of_failures(self): + cache = ModelCache(max_models=2) + reads = [] + + def get(key): + def load(): + reads.append(key) + raise RuntimeError(key) + + return cache.get(key, load) + + get("a") + get("b") + get("c") + get("b") + get("c") + self.assertEqual(reads, ["a", "b", "c"]) + + get("a") + self.assertEqual(reads, ["a", "b", "c", "a"]) + + def test_an_interrupted_read_is_not_cached(self): + cache = ModelCache() + + def interrupt(): + raise KeyboardInterrupt() + + with self.assertRaises(KeyboardInterrupt): + cache.get(KEY, interrupt) + + self.assertEqual(cache.get(KEY, lambda: "model").model, "model") diff --git a/test/data/equivalent-checks-cases.json b/test/data/equivalent-checks-cases.json new file mode 100644 index 00000000..debbb0f7 --- /dev/null +++ b/test/data/equivalent-checks-cases.json @@ -0,0 +1,5598 @@ +{ + "note": "Generated from equivalent-checks-cases.yaml, with every model as JSON. Don't edit this file: edit the YAML file and run `make generate-equivalent-checks-json`.", + "cases": [ + { + "name": "merges an alias with the relation it points to", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges an alias chain into its final relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "d", + "user": "user:bob", + "relation": "can_share", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b", "c"] + }, + { + "id": "d", + "user": "user:bob", + "relation": "viewer", + "object": "document:1", + "for": ["d"] + } + ] + }, + { + "name": "sends a lone alias check with its final relation and the caller's id", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + } + ] + }, + { + "name": "merges exact repeats", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "editor": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "editor", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "editor", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "editor", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "does not merge checks that end at different relations", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_edit": {}, + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_edit": { + "computedUserset": { + "relation": "editor" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_edit", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "editor", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge checks for different users", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "user:bob", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:bob", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge checks on different objects", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:2" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:2", + "for": ["b"] + } + ] + }, + { + "name": "follows the aliases of the object's type only", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "owner": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": {} + } + }, + "relations": { + "owner": { + "this": {} + }, + "viewer": { + "computedUserset": { + "relation": "owner" + } + } + }, + "type": "folder" + }, + { + "metadata": { + "relations": { + "owner": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "owner": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "folder:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "owner", + "object": "folder:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "d", + "user": "user:anne", + "relation": "owner", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "owner", + "object": "folder:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + }, + { + "id": "d", + "user": "user:anne", + "relation": "owner", + "object": "document:1", + "for": ["d"] + } + ] + }, + { + "name": "sends checks in the order of each group's first check", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:bob", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "d", + "user": "user:bob", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "e", + "user": "user:anne", + "relation": "editor", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "c"] + }, + { + "id": "merged-2", + "user": "user:bob", + "relation": "viewer", + "object": "document:1", + "for": ["b", "d"] + }, + { + "id": "e", + "user": "user:anne", + "relation": "editor", + "object": "document:1", + "for": ["e"] + } + ] + }, + { + "name": "stops the chain at a union", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "union": { + "child": [ + { + "computedUserset": { + "relation": "viewer" + } + }, + { + "computedUserset": { + "relation": "editor" + } + } + ] + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "stops the chain at an intersection", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "intersection": { + "child": [ + { + "computedUserset": { + "relation": "viewer" + } + }, + { + "computedUserset": { + "relation": "editor" + } + } + ] + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "stops the chain at an exclusion", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "blocked": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "blocked": { + "this": {} + }, + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "difference": { + "base": { + "computedUserset": { + "relation": "viewer" + } + }, + "subtract": { + "computedUserset": { + "relation": "blocked" + } + } + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "stops the chain at a relation from a parent", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "viewer": { + "this": {} + } + }, + "type": "folder" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "parent": { + "directly_related_user_types": [ + { + "type": "folder" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "tupleToUserset": { + "computedUserset": { + "relation": "viewer" + }, + "tupleset": { + "relation": "parent" + } + } + }, + "parent": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "stops the chain at direct types combined with an alias", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "union": { + "child": [ + { + "this": {} + }, + { + "computedUserset": { + "relation": "viewer" + } + } + ] + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "stops the chain at a union with a single child", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": { + "directly_related_user_types": [] + }, + "can_view": { + "directly_related_user_types": [] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "union": { + "child": [ + { + "computedUserset": { + "relation": "viewer" + } + } + ] + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a", "b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "sends checks as is when the alias chain points to a missing relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "missing" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_share", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "can_share", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "sends checks as is when the alias chain loops", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "loop_a": {}, + "loop_b": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "loop_a": { + "computedUserset": { + "relation": "loop_b" + } + }, + "loop_b": { + "computedUserset": { + "relation": "loop_a" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "loop_a", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "loop_b", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "d", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "loop_a", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "loop_b", + "object": "document:1", + "for": ["b"] + }, + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c", "d"] + } + ] + }, + { + "name": "sends as is a userset on the same object whose relation is the one asked for", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "can_view", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:1#can_share", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "document:1#viewer", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "document:1#can_share", + "relation": "can_share", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "document:1#viewer", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "sends as is a userset on the same object whose relation is in the chain", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "can_view", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:1#can_view", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "document:1#can_view", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "document:1#can_view", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "document:1#can_view", + "relation": "can_share", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "document:1#can_view", + "relation": "can_view", + "object": "document:1", + "for": ["b"] + }, + { + "id": "c", + "user": "document:1#can_view", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "sends as is a userset on the same object whose relation is the final relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "can_view", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:1#viewer", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "document:1#viewer", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "document:1#viewer", + "relation": "can_share", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "document:1#viewer", + "relation": "can_view", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge repeats of a userset on the same object whose relation is in the chain", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "can_view", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:1#can_view", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "document:1#can_view", + "relation": "can_share", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "document:1#can_view", + "relation": "can_share", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "document:1#can_view", + "relation": "can_share", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "merges a userset on the same object whose relation isn't in the chain", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "editor", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:1#editor", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "document:1#editor", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "document:1#editor", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges a userset on the same object whose relation is another alias of the final relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_read": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "can_read", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_read": { + "computedUserset": { + "relation": "viewer" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:1#can_read", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "document:1#can_read", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "document:1#can_read", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges a userset on another object", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "can_view", + "type": "document" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "document:2#can_view", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "document:2#can_view", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "document:2#can_view", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges a userset on an object of another type with the same id", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "viewer": { + "this": {} + } + }, + "type": "folder" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "viewer", + "type": "folder" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "folder:1#viewer", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "folder:1#viewer", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "folder:1#viewer", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges a userset of another type", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "member": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "member": { + "this": {} + } + }, + "type": "team" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "member", + "type": "team" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "team:eng#member", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "team:eng#member", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "team:eng#member", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges a wildcard user", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "type": "user", + "wildcard": {} + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:*", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "b", + "user": "user:*", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:*", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges a user whose type is in the model but not allowed on the relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "member": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "member": { + "this": {} + } + }, + "type": "team" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "team:eng", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "team:eng", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "team:eng", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "sends as is a check on an unknown object type", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "folder:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "folder:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "sends as is a check with an unknown relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_delete", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_delete", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "sends as is a check with an unknown user type", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "employee:1", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "employee:*", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "employee:1#member", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "employee:1", + "relation": "can_view", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "employee:*", + "relation": "can_view", + "object": "document:1", + "for": ["b"] + }, + { + "id": "c", + "user": "employee:1#member", + "relation": "can_view", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "sends as is a check with an unknown userset relation", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "member": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "member": { + "this": {} + } + }, + "type": "team" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "member", + "type": "team" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "team:eng#owner", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "team:eng#member", + "relation": "can_view", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "team:eng#owner", + "relation": "can_view", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "team:eng#member", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge repeats of a check that isn't in the model", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_delete", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_delete", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_delete", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_delete", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "merges checks with the same context", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu" + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu" + } + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges context with keys in a different order", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\" && x > 0.0", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + }, + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu", + "x": 1.5 + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "x": 1.5, + "region": "eu" + } + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges 1 and 1.0", + "model": { + "conditions": { + "positive": { + "expression": "x > 0.0", + "name": "positive", + "parameters": { + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "positive", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "x": 1 + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "x": 1.0 + } + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges integers that are the same double", + "model": { + "conditions": { + "at_most": { + "expression": "x <= 9007199254740992.0", + "name": "at_most", + "parameters": { + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "at_most", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "x": 9007199254740993 + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "x": 9007199254740992 + } + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "does not merge 0 and -0.0", + "model": { + "conditions": { + "negative_sign": { + "expression": "1.0 / x < 0.0", + "name": "negative_sign", + "parameters": { + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "negative_sign", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "x": 0 + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "x": -0.0 + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge true and 1", + "model": { + "conditions": { + "positive": { + "expression": "x > 0.0", + "name": "positive", + "parameters": { + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "positive", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "x": true + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "x": 1 + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge the string \"1\" and the number 1", + "model": { + "conditions": { + "positive": { + "expression": "x > 0.0", + "name": "positive", + "parameters": { + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "positive", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "x": "1" + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "x": 1 + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge when a field is missing", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu", + "x": 1 + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu" + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge null and a missing field", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu", + "x": null + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu" + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge no context and an empty context", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": {} + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge strings that differ by a trailing space", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu" + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu " + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge strings that differ only in Unicode form", + "model": { + "conditions": { + "in_city": { + "expression": "city == \"caf\\u00e9\"", + "name": "in_city", + "parameters": { + "city": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_city", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "city": "caf\u00e9" + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "city": "cafe\u0301" + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge lists in a different order", + "model": { + "conditions": { + "first_tag": { + "expression": "tags[0] == \"a\"", + "name": "first_tag", + "parameters": { + "tags": { + "generic_types": [ + { + "type_name": "TYPE_NAME_STRING" + } + ], + "type_name": "TYPE_NAME_LIST" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "first_tag", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "tags": ["a", "b"] + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "tags": ["b", "a"] + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge lists with a repeated item", + "model": { + "conditions": { + "one_tag": { + "expression": "size(tags) == 1", + "name": "one_tag", + "parameters": { + "tags": { + "generic_types": [ + { + "type_name": "TYPE_NAME_STRING" + } + ], + "type_name": "TYPE_NAME_LIST" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "one_tag", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "tags": ["a"] + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "tags": ["a", "a"] + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "merges nested values that match at every level", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu", + "meta": { + "level": 1, + "items": [ + 1, + { + "k": "v", + "count": 2 + } + ] + } + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "meta": { + "items": [ + 1.0, + { + "count": 2.0, + "k": "v" + } + ], + "level": 1.0 + }, + "region": "eu" + } + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "does not merge nested values that differ", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu", + "meta": { + "items": [0] + } + } + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu", + "meta": { + "items": [-0.0] + } + } + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu", + "meta": { + "items": [false] + } + } + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "merges checks with the same contextual tuples", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "member": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "member": { + "this": {} + } + }, + "type": "team" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "member", + "type": "team" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "member", + "object": "team:eng" + }, + { + "user": "team:eng#member", + "relation": "viewer", + "object": "document:1" + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "member", + "object": "team:eng" + }, + { + "user": "team:eng#member", + "relation": "viewer", + "object": "document:1" + } + ] + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "merges contextual tuples in a different order", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "member": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "member": { + "this": {} + } + }, + "type": "team" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "relation": "member", + "type": "team" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "member", + "object": "team:eng" + }, + { + "user": "team:eng#member", + "relation": "viewer", + "object": "document:1" + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "team:eng#member", + "relation": "viewer", + "object": "document:1" + }, + { + "user": "user:anne", + "relation": "member", + "object": "team:eng" + } + ] + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "does not merge different contextual tuples", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:2" + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge no contextual tuples and some", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge no contextual tuples and an empty list", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "sends as is a check whose contextual tuples repeat a tuple", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ] + }, + { + "id": "c", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "does not merge a contextual tuple with and without a condition", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + }, + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region", + "context": { + "region": "us" + } + } + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge contextual tuples with different condition names", + "model": { + "conditions": { + "in_eu": { + "expression": "region == \"eu\"", + "name": "in_eu", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + }, + "in_us": { + "expression": "region == \"us\"", + "name": "in_us", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "condition": "in_eu", + "type": "user" + }, + { + "condition": "in_us", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_eu", + "context": { + "region": "eu" + } + } + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_us", + "context": { + "region": "eu" + } + } + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge contextual tuples with different condition context", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region", + "context": { + "region": "eu" + } + } + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region", + "context": { + "region": "us" + } + } + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "does not merge a contextual tuple condition with no context and an empty context", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\"", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "context": { + "region": "eu" + }, + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region" + } + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "context": { + "region": "eu" + }, + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region", + "context": {} + } + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + } + ] + }, + { + "name": "merges contextual tuple condition context that matches at every level", + "model": { + "conditions": { + "in_region": { + "expression": "region == \"eu\" && x > 0.0", + "name": "in_region", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + }, + "x": { + "type_name": "TYPE_NAME_DOUBLE" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "condition": "in_region", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region", + "context": { + "region": "eu", + "x": 1 + } + } + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_region", + "context": { + "x": 1.0, + "region": "eu" + } + } + } + ] + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["a", "b"] + } + ] + }, + { + "name": "sends as is checks whose contextual tuples repeat a user, relation and object with different conditions", + "model": { + "conditions": { + "in_eu": { + "expression": "region == \"eu\"", + "name": "in_eu", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + }, + "in_us": { + "expression": "region == \"us\"", + "name": "in_us", + "parameters": { + "region": { + "type_name": "TYPE_NAME_STRING" + } + } + } + }, + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "condition": "in_eu", + "type": "user" + }, + { + "condition": "in_us", + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_eu", + "context": { + "region": "eu" + } + } + }, + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_us", + "context": { + "region": "eu" + } + } + } + ] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_us", + "context": { + "region": "eu" + } + } + }, + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_eu", + "context": { + "region": "eu" + } + } + } + ] + }, + { + "id": "c", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "contextual_tuples": [ + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_eu", + "context": { + "region": "eu" + } + } + }, + { + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "condition": { + "name": "in_us", + "context": { + "region": "eu" + } + } + } + ] + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["a"] + }, + { + "id": "b", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b"] + }, + { + "id": "c", + "user": "user:anne", + "relation": "can_view", + "object": "document:1", + "for": ["c"] + } + ] + }, + { + "name": "numbers merged groups in order and skips groups of one", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_share": {}, + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_share": { + "computedUserset": { + "relation": "can_view" + } + }, + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "a", + "user": "user:anne", + "relation": "editor", + "object": "document:1" + }, + { + "id": "b", + "user": "user:bob", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:bob", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "d", + "user": "user:anne", + "relation": "can_share", + "object": "document:1" + }, + { + "id": "e", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "a", + "user": "user:anne", + "relation": "editor", + "object": "document:1", + "for": ["a"] + }, + { + "id": "merged-1", + "user": "user:bob", + "relation": "viewer", + "object": "document:1", + "for": ["b", "c"] + }, + { + "id": "merged-2", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["d", "e"] + } + ] + }, + { + "name": "skips a generated id that matches a caller's id", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "editor": { + "this": {} + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "editor", + "object": "document:1" + }, + { + "id": "b", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "c", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "editor", + "object": "document:1", + "for": ["merged-1"] + }, + { + "id": "merged-2", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["b", "c"] + } + ] + }, + { + "name": "skips generated ids that match caller ids inside merged groups", + "model": { + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user" + }, + { + "metadata": { + "relations": { + "can_view": {}, + "viewer": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + } + } + }, + "relations": { + "can_view": { + "computedUserset": { + "relation": "viewer" + } + }, + "viewer": { + "this": {} + } + }, + "type": "document" + } + ] + }, + "checks": [ + { + "id": "merged-1", + "user": "user:anne", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "merged-2", + "user": "user:anne", + "relation": "viewer", + "object": "document:1" + }, + { + "id": "c", + "user": "user:bob", + "relation": "can_view", + "object": "document:1" + }, + { + "id": "d", + "user": "user:bob", + "relation": "viewer", + "object": "document:1" + } + ], + "expected_sent": [ + { + "id": "merged-3", + "user": "user:anne", + "relation": "viewer", + "object": "document:1", + "for": ["merged-1", "merged-2"] + }, + { + "id": "merged-4", + "user": "user:bob", + "relation": "viewer", + "object": "document:1", + "for": ["c", "d"] + } + ] + } + ] +} diff --git a/test/sync/client/batch_check_merge_test.py b/test/sync/client/batch_check_merge_test.py new file mode 100644 index 00000000..38e02c44 --- /dev/null +++ b/test/sync/client/batch_check_merge_test.py @@ -0,0 +1,347 @@ +import json + +from concurrent.futures import ThreadPoolExecutor +from unittest import TestCase +from unittest.mock import patch + +from openfga_sdk.client import ClientConfiguration +from openfga_sdk.client.models.batch_check_request import ClientBatchCheckRequest +from openfga_sdk.exceptions import FgaValidationException, ForbiddenException +from openfga_sdk.sync import rest +from openfga_sdk.sync.client.client import OpenFgaClient +from openfga_sdk.telemetry.attributes import TelemetryAttributes +from openfga_sdk.telemetry.metrics import TelemetryMetrics +from test.client.batch_check_merge_test import ( + API_URL, + BATCH_CHECK_URL, + MODEL, + MODEL_ID, + MODEL_URL, + STORE_ID, + checks, + http_mock_response, + sent_checks, +) + + +def mock_response(body, status=200): + obj = http_mock_response(body, status) + return rest.RESTResponse(obj, obj.data) + + +class FakeServer: + """ + Answers model reads and BatchCheck requests, and records what was sent. + """ + + def __init__(self, model_error=None, answer=None, model_body=None): + self.model_error = model_error + self.model_body = ( + {"authorization_model": MODEL} if model_body is None else model_body + ) + self.answer = answer or ( + lambda check: {"allowed": check["tuple_key"]["relation"] == "viewer"} + ) + self.model_reads = 0 + self.model_read_headers = [] + self.sent = [] + + def __call__(self, method, url, **kwargs): + if method == "GET" and url == MODEL_URL: + self.model_reads += 1 + self.model_read_headers.append(kwargs.get("headers") or {}) + if self.model_error is not None: + raise self.model_error + return mock_response(json.dumps(self.model_body)) + + if method == "POST" and url == BATCH_CHECK_URL: + body = kwargs["body"] + self.sent.append(body) + return mock_response( + json.dumps( + { + "result": { + check["correlation_id"]: self.answer(check) + for check in body["checks"] + } + } + ) + ) + + raise AssertionError(f"Unexpected request: {method} {url}") + + +def forbidden(): + return ForbiddenException(http_resp=http_mock_response("{}", 403)) + + +class TestSyncBatchCheckMergeEquivalentChecks(TestCase): + def setUp(self): + self.configuration = ClientConfiguration( + api_url=API_URL, + store_id=STORE_ID, + authorization_model_id=MODEL_ID, + ) + + def batch_check(self, client, items, options=None): + return client.batch_check( + ClientBatchCheckRequest(checks=items), + options={"merge_equivalent_checks": True, **(options or {})}, + ) + + @patch.object(rest.RESTClientObject, "request") + def test_merges_aliases_and_copies_the_answer_back(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + items = checks() + + with OpenFgaClient(self.configuration) as client: + response = self.batch_check(client, items) + + self.assertEqual(server.model_reads, 1) + self.assertEqual(sent_checks(server), [("merged-1", "viewer"), ("d", "editor")]) + self.assertEqual(server.sent[0]["authorization_model_id"], MODEL_ID) + self.assertEqual(response.checks_sent, 2) + self.assertEqual( + [(r.correlation_id, r.allowed, r.error) for r in response.result], + [ + ("a", True, None), + ("b", True, None), + ("c", True, None), + ("d", False, None), + ], + ) + self.assertEqual([r.request for r in response.result], items) + + @patch.object(rest.RESTClientObject, "request") + def test_merges_checks_that_would_land_in_different_batches(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + + with OpenFgaClient(self.configuration) as client: + response = self.batch_check( + client, checks()[:3], options={"max_batch_size": 1} + ) + + self.assertEqual(sent_checks(server), [("merged-1", "viewer")]) + self.assertEqual(response.checks_sent, 1) + self.assertEqual(len(response.result), 3) + + @patch.object(rest.RESTClientObject, "request") + def test_reads_the_model_once_across_threads_and_later_calls(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + + with OpenFgaClient(self.configuration) as client: + with ThreadPoolExecutor(max_workers=5) as executor: + list( + executor.map(lambda _: self.batch_check(client, checks()), range(5)) + ) + self.batch_check(client, checks()) + + self.assertEqual(server.model_reads, 1) + self.assertEqual(len(server.sent), 6) + + @patch.object(rest.RESTClientObject, "request") + def test_requires_a_model_id(self, mock_request): + self.configuration.authorization_model_id = None + + with OpenFgaClient(self.configuration) as client: + with self.assertRaises(FgaValidationException): + self.batch_check(client, checks()) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + def test_rejects_an_option_value_that_is_not_a_bool(self, mock_request): + with OpenFgaClient(self.configuration) as client: + with self.assertRaisesRegex( + FgaValidationException, "merge_equivalent_checks must be a bool" + ): + self.batch_check( + client, checks(), options={"merge_equivalent_checks": 1} + ) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + def test_reads_the_model_with_the_callers_headers(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + + with OpenFgaClient(self.configuration) as client: + self.batch_check(client, checks(), options={"headers": {"X-Custom": "1"}}) + + self.assertEqual(server.model_read_headers[0].get("X-Custom"), "1") + + @patch.object(rest.RESTClientObject, "request") + def test_rejects_invalid_and_duplicate_correlation_ids(self, mock_request): + invalid = checks() + invalid[0].correlation_id = "a\n" + duplicate = checks() + duplicate[1].correlation_id = "a" + + with OpenFgaClient(self.configuration) as client: + with self.assertRaises(FgaValidationException): + self.batch_check(client, invalid) + with self.assertRaisesRegex( + FgaValidationException, r"Duplicate correlation_id \(a\) provided" + ): + self.batch_check(client, duplicate) + + mock_request.assert_not_called() + + @patch.object(rest.RESTClientObject, "request") + def test_generates_missing_correlation_ids(self, mock_request): + mock_request.side_effect = FakeServer() + items = checks() + items[3].correlation_id = None + + with OpenFgaClient(self.configuration) as client: + response = self.batch_check(client, items) + + self.assertIsNotNone(items[3].correlation_id) + self.assertEqual(response.result[3].correlation_id, items[3].correlation_id) + + @patch.object(rest.RESTClientObject, "request") + def test_no_checks_sends_nothing(self, mock_request): + with OpenFgaClient(self.configuration) as client: + response = self.batch_check(client, []) + + self.assertEqual(response.result, []) + self.assertEqual(response.checks_sent, 0) + mock_request.assert_not_called() + + @patch.object(TelemetryMetrics, "batchCheckMergeFallback") + @patch.object(rest.RESTClientObject, "request") + def test_sends_unmerged_when_the_model_cannot_be_read( + self, mock_request, mock_fallback + ): + server = FakeServer(model_error=forbidden()) + mock_request.side_effect = server + + with OpenFgaClient(self.configuration) as client: + with self.assertLogs("openfga_sdk.sync.client.client", "WARNING") as logs: + response = self.batch_check(client, checks()) + self.batch_check(client, checks()) + + self.assertEqual(len(logs.records), 1) + self.assertIn("403", logs.output[0]) + self.assertEqual(server.model_reads, 1) + unmerged = [ + ("a", "viewer"), + ("b", "can_view"), + ("c", "can_share"), + ("d", "editor"), + ] + self.assertEqual(sent_checks(server), unmerged * 2) + self.assertEqual(response.checks_sent, 4) + self.assertEqual(mock_fallback.call_count, 2) + self.assertEqual( + mock_fallback.call_args.kwargs["attributes"][ + TelemetryAttributes.http_response_status_code + ], + 403, + ) + + @patch.object(rest.RESTClientObject, "request") + def test_sends_unmerged_when_the_model_response_is_empty(self, mock_request): + server = FakeServer(model_body={}) + mock_request.side_effect = server + + with OpenFgaClient(self.configuration) as client: + with self.assertLogs("openfga_sdk.sync.client.client", "WARNING") as logs: + response = self.batch_check(client, checks()) + + self.assertEqual(len(logs.records), 1) + self.assertEqual( + sent_checks(server), + [("a", "viewer"), ("b", "can_view"), ("c", "can_share"), ("d", "editor")], + ) + self.assertEqual(response.checks_sent, 4) + + @patch.object(rest.RESTClientObject, "request") + def test_copies_an_error_to_every_check_in_the_group(self, mock_request): + mock_request.side_effect = FakeServer( + answer=lambda check: ( + {"error": {"message": "boom"}} + if check["correlation_id"] == "merged-1" + else {"allowed": True} + ) + ) + + with OpenFgaClient(self.configuration) as client: + response = self.batch_check(client, checks()) + + for result in response.result[:3]: + self.assertFalse(result.allowed) + self.assertEqual(result.error.message, "boom") + self.assertTrue(response.result[3].allowed) + + @patch.object(rest.RESTClientObject, "request") + def test_a_missing_answer_is_an_error_and_unknown_answers_are_ignored( + self, mock_request + ): + def handle(method, url, **kwargs): + if method == "GET": + return mock_response(json.dumps({"authorization_model": MODEL})) + return mock_response( + json.dumps( + {"result": {"d": {"allowed": True}, "unknown": {"allowed": True}}} + ) + ) + + mock_request.side_effect = handle + + with OpenFgaClient(self.configuration) as client: + response = self.batch_check(client, checks()) + + self.assertEqual( + [r.correlation_id for r in response.result], ["a", "b", "c", "d"] + ) + for result in response.result[:3]: + self.assertFalse(result.allowed) + self.assertEqual( + result.error.message, "No result returned for correlation_id (merged-1)" + ) + self.assertTrue(response.result[3].allowed) + + @patch.object(rest.RESTClientObject, "request") + def test_option_off_is_unchanged(self, mock_request): + server = FakeServer() + mock_request.side_effect = server + + with OpenFgaClient(self.configuration) as client: + response = client.batch_check(ClientBatchCheckRequest(checks=checks())) + + self.assertEqual(server.model_reads, 0) + self.assertEqual( + sent_checks(server), + [("a", "viewer"), ("b", "can_view"), ("c", "can_share"), ("d", "editor")], + ) + self.assertIsNone(response.checks_sent) + + @patch.object(TelemetryMetrics, "batchCheckChecksSent") + @patch.object(TelemetryMetrics, "batchCheckChecksRequested") + @patch.object(TelemetryMetrics, "modelCacheLookup") + @patch.object(rest.RESTClientObject, "request") + def test_records_telemetry( + self, mock_request, mock_lookup, mock_requested, mock_sent + ): + mock_request.side_effect = FakeServer() + + with OpenFgaClient(self.configuration) as client: + self.batch_check(client, checks()) + self.batch_check(client, checks()) + + self.assertEqual( + [ + call.kwargs["attributes"][ + TelemetryAttributes.fga_client_model_cache_result + ] + for call in mock_lookup.call_args_list + ], + ["miss", "hit"], + ) + self.assertEqual(mock_requested.call_args.args, (4,)) + self.assertEqual(mock_sent.call_args.args, (2,)) diff --git a/test/telemetry/configuration_test.py b/test/telemetry/configuration_test.py index 7b3761a4..48562217 100644 --- a/test/telemetry/configuration_test.py +++ b/test/telemetry/configuration_test.py @@ -282,18 +282,22 @@ def test_default_telemetry_metrics_configuration(): metrics_config = TelemetryMetricsConfiguration.getSdkDefaults() assert isinstance(metrics_config, dict) - assert len(metrics_config) == 3 + assert len(metrics_config) == 7 assert TelemetryCounters.fga_client_credentials_request in metrics_config assert TelemetryHistograms.fga_client_query_duration in metrics_config assert TelemetryHistograms.fga_client_request_duration in metrics_config + assert TelemetryCounters.fga_client_batch_check_checks_requested in metrics_config + assert TelemetryCounters.fga_client_batch_check_checks_sent in metrics_config + assert TelemetryCounters.fga_client_model_cache_lookup in metrics_config + assert TelemetryCounters.fga_client_batch_check_merge_fallback in metrics_config def test_default_telemetry_metric_configuration(): metric_config = TelemetryMetricConfiguration.getSdkDefaults() assert isinstance(metric_config, dict) - assert len(metric_config) == 16 + assert len(metric_config) == 17 assert ( metric_config[TelemetryAttributes.fga_client_request_batch_check_size] is False @@ -304,6 +308,7 @@ def test_default_telemetry_metric_configuration(): assert metric_config[TelemetryAttributes.fga_client_request_store_id] is True assert metric_config[TelemetryAttributes.fga_client_response_model_id] is True assert metric_config[TelemetryAttributes.fga_client_user] is False + assert metric_config[TelemetryAttributes.fga_client_model_cache_result] is True assert metric_config[TelemetryAttributes.http_client_request_duration] is False assert metric_config[TelemetryAttributes.http_host] is True assert metric_config[TelemetryAttributes.http_request_method] is True diff --git a/test/telemetry/metrics_test.py b/test/telemetry/metrics_test.py index 1de3af4c..93457f3a 100644 --- a/test/telemetry/metrics_test.py +++ b/test/telemetry/metrics_test.py @@ -4,6 +4,8 @@ from opentelemetry.metrics import Counter, Histogram, Meter +from openfga_sdk.telemetry.attributes import TelemetryAttributes +from openfga_sdk.telemetry.configuration import TelemetryConfiguration from openfga_sdk.telemetry.counters import TelemetryCounters from openfga_sdk.telemetry.histograms import TelemetryHistograms from openfga_sdk.telemetry.metrics import TelemetryMetrics @@ -80,3 +82,86 @@ def test_invalid_histogram_key(): telemetry = TelemetryMetrics() with pytest.raises(ValueError): telemetry.histogram("invalid_histogram_key") + + +@patch("openfga_sdk.telemetry.metrics.get_meter") +def test_batch_check_merge_counters_use_their_attribute_filters(mock_get_meter): + mock_meter = MagicMock(spec=Meter) + mock_counter = MagicMock(spec=Counter) + mock_get_meter.return_value = mock_meter + mock_meter.create_counter.return_value = mock_counter + + telemetry = TelemetryMetrics() + configuration = TelemetryConfiguration( + { + "metrics": { + "fga-client.model_cache.lookup": { + "fga-client.model_cache.result": True, + }, + } + } + ) + + telemetry.modelCacheLookup( + attributes={ + TelemetryAttributes.fga_client_model_cache_result: "hit", + TelemetryAttributes.fga_client_request_store_id: "store", + }, + configuration=configuration, + ) + mock_counter.add.assert_called_once_with( + amount=1, attributes={"fga-client.model_cache.result": "hit"} + ) + + # Not configured, so not recorded + mock_counter.add.reset_mock() + telemetry.batchCheckChecksSent(2, configuration=configuration) + telemetry.batchCheckChecksRequested(4, configuration=configuration) + telemetry.batchCheckMergeFallback(configuration=configuration) + mock_counter.add.assert_not_called() + + +@patch("openfga_sdk.telemetry.metrics.get_meter") +def test_batch_check_merge_counters_are_on_by_default(mock_get_meter): + mock_meter = MagicMock(spec=Meter) + mock_counter = MagicMock(spec=Counter) + mock_get_meter.return_value = mock_meter + mock_meter.create_counter.return_value = mock_counter + + telemetry = TelemetryMetrics() + configuration = TelemetryConfiguration(TelemetryConfiguration.getSdkDefaults()) + attributes = { + TelemetryAttributes.fga_client_request_store_id: "store", + TelemetryAttributes.fga_client_request_model_id: "model", + } + + telemetry.batchCheckChecksRequested(4, attributes, configuration) + telemetry.batchCheckChecksSent(2, attributes, configuration) + telemetry.batchCheckMergeFallback( + attributes={**attributes, TelemetryAttributes.http_response_status_code: 403}, + configuration=configuration, + ) + telemetry.modelCacheLookup( + attributes={ + **attributes, + TelemetryAttributes.fga_client_model_cache_result: "miss", + }, + configuration=configuration, + ) + + expected = { + "fga-client.request.store_id": "store", + "fga-client.request.model_id": "model", + } + assert [call.kwargs for call in mock_counter.add.call_args_list] == [ + {"amount": 4, "attributes": expected}, + {"amount": 2, "attributes": expected}, + { + "amount": 1, + "attributes": {**expected, "http.response.status_code": 403}, + }, + { + "amount": 1, + "attributes": {**expected, "fga-client.model_cache.result": "miss"}, + }, + ]