From 04191110e3313d27a363e98c52c0ec0af0a26d0b Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:13:15 +0300 Subject: [PATCH 01/20] Send X-Wait-Timeout when the facts sync timeout is 0 facts_sync_timeout=0 and wait_for_sync(timeout=0) sent no X-Wait-Timeout header, because the header was set only for a truthy timeout, so the PDP waited its own default instead of answering without waiting. The header is now sent whenever the timeout is not None (PER-16681). X-Timeout-Policy keeps its check: none of its values is falsy. Co-Authored-By: Claude Opus 5.5 --- permit/api/base.py | 2 +- tests/test_facts_sync_offline.py | 207 +++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 tests/test_facts_sync_offline.py diff --git a/permit/api/base.py b/permit/api/base.py index cb34c96e..38e0e5c6 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -328,7 +328,7 @@ def _build_http_client( ) -> SimpleHttpClient: optional_headers = {} if self.config.proxy_facts_via_pdp: - if self.config.facts_sync_timeout: + if self.config.facts_sync_timeout is not None: optional_headers["X-Wait-Timeout"] = str(self.config.facts_sync_timeout) if self.config.facts_sync_timeout_policy: optional_headers["X-Timeout-Policy"] = str(self.config.facts_sync_timeout_policy) diff --git a/tests/test_facts_sync_offline.py b/tests/test_facts_sync_offline.py new file mode 100644 index 00000000..d9394c26 --- /dev/null +++ b/tests/test_facts_sync_offline.py @@ -0,0 +1,207 @@ +"""Offline tests for the headers that make the PDP wait for a proxied facts write. + +With ``proxy_facts_via_pdp`` on, the SDK sends its facts requests to the PDP, with the +``X-Wait-Timeout`` header when a facts sync timeout is set (PER-16681). Each call goes +through the async and the blocking client, and the test checks the request it puts on the +wire (method, path, query string, headers and JSON body). Every request is served by a local +``pytest_httpserver``, the API and the PDP each on a server of their own, and the API context +is pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. +""" + +import asyncio +import inspect +from contextlib import nullcontext +from operator import attrgetter +from typing import Any + +import pytest +from pytest_httpserver import HTTPServer +from werkzeug import Request + +from permit import Permit +from permit.config import PermitConfig +from permit.sync import Permit as SyncPermit +from tests.utils import FACTS, Call, call, offline_config, sent + +FLAVOURS = ["async", "sync"] + +HEADERS = ("Authorization", "Content-Type", "X-Wait-Timeout", "X-Timeout-Policy") + +ENVIRONMENT_ID = "6a1b2c3d-0000-4000-8000-000000000003" +PROJECT_ID = "6a1b2c3d-0000-4000-8000-000000000002" +ORGANIZATION_ID = "6a1b2c3d-0000-4000-8000-000000000001" +CREATED_AT = "2026-01-01T00:00:00+00:00" +USER = { + "key": "alice", + "id": "6a1b2c3d-0000-4000-8000-000000000010", + "organization_id": ORGANIZATION_ID, + "project_id": PROJECT_ID, + "environment_id": ENVIRONMENT_ID, + "created_at": CREATED_AT, + "updated_at": CREATED_AT, +} + +CREATE_USER = call("api.users.create", {"key": "alice"}) +CREATE_USER_SENT = {"method": "POST", "path": "/facts/users", "query": [], "body": {"key": "alice"}} + + +@pytest.fixture +def pdp_server(httpserver_ipv4: HTTPServer) -> HTTPServer: + """A server of its own for the PDP, so a request reaching it is told from one to the API.""" + return httpserver_ipv4 + + +def make_config(api: HTTPServer, pdp: HTTPServer, **options: Any) -> PermitConfig: + """An offline config for the API on ``api`` and the PDP on ``pdp``, with ``options``. + + The options go to ``PermitConfig`` itself, which validates them as it does for an + application that passes them. + """ + offline = offline_config(api.url_for("").rstrip("/")) + return PermitConfig( + token=offline.token, + api_url=offline.api_url, + pdp=pdp.url_for("").rstrip("/"), + api_context=offline.api_context, + **options, + ) + + +async def _invoke_async(config: PermitConfig, target: Call, wait: dict[str, Any] | None) -> object: + async with Permit(config) as permit: + with nullcontext(permit) if wait is None else permit.wait_for_sync(**wait) as client: + return await attrgetter(target.path)(client)(*target.args, **target.kwargs) + + +def invoke( + config: PermitConfig, flavour: str, target: Call, wait: dict[str, Any] | None = None +) -> object: + """Call ``permit.`` on the async or the blocking client, then close it. + + With ``wait``, the call goes through the client ``permit.wait_for_sync(**wait)`` yields. + """ + if flavour == "async": + return asyncio.run(_invoke_async(config, target, wait)) + with ( + SyncPermit(config) as permit, + nullcontext(permit) if wait is None else permit.wait_for_sync(**wait) as client, + ): + result = attrgetter(target.path)(client)(*target.args, **target.kwargs) + assert not inspect.isawaitable(result) + return result + + +def sent_headers(request: Request) -> dict[str, str | None]: + return {name: request.headers.get(name) for name in HEADERS} + + +def facts_headers(wait_timeout: str | None, policy: str | None) -> dict[str, str | None]: + return { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + "X-Wait-Timeout": wait_timeout, + "X-Timeout-Policy": policy, + } + + +# --- the timeout in the config --------------------------------------------------------- + +# PermitConfig validates facts_sync_timeout as a float, so an int is sent as "3.0". +CONFIG_TIMEOUTS = { + "unset": (None, None), + "zero": (0, "0.0"), + "zero-float": (0.0, "0.0"), + "int": (3, "3.0"), + "float": (2.5, "2.5"), +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + ("timeout", "header"), CONFIG_TIMEOUTS.values(), ids=CONFIG_TIMEOUTS.keys() +) +def test_facts_sync_timeout_is_sent_unless_it_is_none( + *, + httpserver: HTTPServer, + pdp_server: HTTPServer, + timeout: float | None, + header: str | None, + flavour: str, +) -> None: + """0 is sent too, so the PDP answers without waiting instead of waiting its default.""" + config = make_config( + httpserver, pdp_server, proxy_facts_via_pdp=True, facts_sync_timeout=timeout + ) + pdp_server.expect_request("/facts/users", method="POST").respond_with_json(USER) + + invoke(config, flavour, CREATE_USER) + + [(request, _)] = pdp_server.log + assert sent(request) == CREATE_USER_SENT + assert sent_headers(request) == facts_headers(header, None) + assert httpserver.log == [] + + +# --- the timeout of wait_for_sync() --------------------------------------------------- + +# wait_for_sync() sets the timeout it is given on its client's config as it is, so an int +# is sent as "3". Its own timeout replaces the config's, 0 included; its policy replaces +# the config's only when it is given. +WAIT_FOR_SYNC_TIMEOUTS = { + "default": ({}, "10.0", "fail"), + "zero": ({"timeout": 0}, "0", "fail"), + "zero-float": ({"timeout": 0.0}, "0.0", "fail"), + "int": ({"timeout": 3}, "3", "fail"), + "float": ({"timeout": 2.5}, "2.5", "fail"), + "zero-policy": ({"timeout": 0, "policy": "ignore"}, "0", "ignore"), +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + ("wait", "header", "policy"), + WAIT_FOR_SYNC_TIMEOUTS.values(), + ids=WAIT_FOR_SYNC_TIMEOUTS.keys(), +) +def test_wait_for_sync_sends_its_timeout_even_when_it_is_zero( + *, + httpserver: HTTPServer, + pdp_server: HTTPServer, + wait: dict[str, Any], + header: str, + policy: str, + flavour: str, +) -> None: + config = make_config( + httpserver, + pdp_server, + proxy_facts_via_pdp=True, + facts_sync_timeout=7.5, + facts_sync_timeout_policy="fail", + ) + pdp_server.expect_request("/facts/users", method="POST").respond_with_json(USER) + + invoke(config, flavour, CREATE_USER, wait) + + [(request, _)] = pdp_server.log + assert sent(request) == CREATE_USER_SENT + assert sent_headers(request) == facts_headers(header, policy) + assert httpserver.log == [] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_without_proxy_facts_via_pdp_a_zero_timeout_sends_no_header( + httpserver: HTTPServer, pdp_server: HTTPServer, flavour: str +) -> None: + """The facts request goes to the API, which does not wait, so nothing tells it to.""" + config = make_config( + httpserver, pdp_server, facts_sync_timeout=0, facts_sync_timeout_policy="fail" + ) + httpserver.expect_request(f"{FACTS}/users", method="POST").respond_with_json(USER) + + invoke(config, flavour, CREATE_USER) + + [(request, _)] = httpserver.log + assert sent(request) == {**CREATE_USER_SENT, "path": f"{FACTS}/users"} + assert sent_headers(request) == facts_headers(None, None) + assert pdp_server.log == [] From 14678315e2e5a104a6ab1f0e39913c160b3c431e Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:15:45 +0300 Subject: [PATCH 02/20] Build the container-PDP-only 404 message in one place get_user_tenants() now builds its 404 message with a helper that the other container-only routes can share. The text is unchanged; a new test pins it exactly. SETUP_PDP_DOCS_LINK moves next to the helper and stays importable from permit.enforcement.enforcer. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- permit/enforcement/enforcer.py | 15 +++---- permit/utils/cloud_pdp.py | 32 +++++++++++++ tests/test_container_pdp_only_offline.py | 57 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 permit/utils/cloud_pdp.py create mode 100644 tests/test_container_pdp_only_offline.py diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 32c5bf71..edeac61c 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -15,6 +15,10 @@ UserInput, ) from permit.exceptions import PermitConnectionError + +# Re-exported: this module defined the link before the module that now does. +from permit.utils.cloud_pdp import SETUP_PDP_DOCS_LINK as SETUP_PDP_DOCS_LINK +from permit.utils.cloud_pdp import container_pdp_only_message from permit.utils.context import Context, ContextStore from permit.utils.dicts import deep_merge from permit.utils.http_sessions import LoopSessions @@ -88,9 +92,6 @@ class CheckQuery(TypedDict): context: NotRequired[Context | None] -SETUP_PDP_DOCS_LINK = "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" - - class _TimeoutConfig(TypedDict, total=False): timeout: ClientTimeout @@ -612,12 +613,8 @@ async def get_user_tenants( url, data=json.dumps(body), headers=self._headers, **self._timeout_config ) as response: if response.status == HTTPStatus.NOT_FOUND: - msg = ( - f"permit.get_user_tenants() got status code 404 from the PDP at " - f"{self._base_url}: only the container PDP serves /user-tenants, " - f"and the cloud PDP does not.\n" - f"Point the SDK's `pdp` setting at a container PDP to use it.\n" - f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" + msg = container_pdp_only_message( + "permit.get_user_tenants()", "/user-tenants", self._base_url ) raise PermitConnectionError(msg) if response.status != HTTPStatus.OK: diff --git a/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py new file mode 100644 index 00000000..c3823e89 --- /dev/null +++ b/permit/utils/cloud_pdp.py @@ -0,0 +1,32 @@ +"""The routes only a container PDP serves, and the hosted cloud PDP does not. + +The cloud PDP serves the decision routes (``/allowed``, ``/allowed/bulk``, +``/authorized_users``, ``/user-permissions`` and AuthZEN) and ``/health``. A container PDP +serves those too, and also ``/user-tenants``. +""" + +SETUP_PDP_DOCS_LINK = "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" + +USE_A_CONTAINER_PDP = "Point the SDK's `pdp` setting at a container PDP to use it." + + +def container_pdp_only_message( + caller: str, route: str, pdp_url: str, advice: str = USE_A_CONTAINER_PDP +) -> str: + """The error message for a route only the container PDP serves, that a PDP answered 404. + + Args: + caller: What sent the request, such as ``permit.get_user_tenants()``. + route: The route the PDP answered 404 for, such as ``/user-tenants``. + pdp_url: The address of that PDP, as the SDK's ``pdp`` setting gives it. + advice: What to do instead. + + Returns: + The message, which names the route and says that it needs the container PDP. + """ + return ( + f"{caller} got status code 404 from the PDP at {pdp_url}: only the container PDP " + f"serves {route}, and the cloud PDP does not.\n" + f"{advice}\n" + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" + ) diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py new file mode 100644 index 00000000..0016b285 --- /dev/null +++ b/tests/test_container_pdp_only_offline.py @@ -0,0 +1,57 @@ +"""Offline tests for the routes only the container PDP serves (PER-16340). + +The hosted cloud PDP serves the decision routes and ``/health``. It answers 404 for +``/user-tenants``, which ``get_user_tenants()`` calls, and the SDK raises that 404 as an +error that names the route and says it needs the container PDP. + +Each call goes through the async and the blocking client, each closed once the call +returns. Every request is served by a local ``pytest_httpserver`` and the API context is +pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. +""" + +import asyncio +import inspect +from operator import attrgetter + +import pytest +from pytest_httpserver import HTTPServer + +from permit import Permit, PermitConnectionError +from permit.config import PermitConfig +from permit.sync import Permit as SyncPermit +from tests.utils import Call, call + +FLAVOURS = ["async", "sync"] +DOCS_LINK = "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" + + +def invoke(config: PermitConfig, flavour: str, target: Call) -> object: + """Call ``permit.`` on a new async or blocking client, and close the client.""" + if flavour == "async": + + async def call_awaiting() -> object: + async with Permit(config) as permit: + return await attrgetter(target.path)(permit)(*target.args, **target.kwargs) + + return asyncio.run(call_awaiting()) + with SyncPermit(config) as permit: + result = attrgetter(target.path)(permit)(*target.args, **target.kwargs) + assert not inspect.isawaitable(result) + return result + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_get_user_tenants_names_the_route_and_asks_for_a_container_pdp( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + httpserver.expect_request("/user-tenants", method="POST").respond_with_data("", status=404) + + with pytest.raises(PermitConnectionError) as raised: + invoke(config, flavour, call("get_user_tenants", "alice")) + + assert str(raised.value) == ( + f"permit.get_user_tenants() got status code 404 from the PDP at {config.pdp}: only " + "the container PDP serves /user-tenants, and the cloud PDP does not.\n" + "Point the SDK's `pdp` setting at a container PDP to use it.\n" + f"Read more about setting up the PDP at {DOCS_LINK}" + ) From 79f604394ed4d8cc30b22cb694de8ee8e605edf2 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:22:53 +0300 Subject: [PATCH 03/20] Say when a facts or pdp_api route needs the container PDP The cloud PDP answers 404, with an empty body, for the /facts routes that proxy_facts_via_pdp sends facts to and for the /local routes of permit.pdp_api. The SDK raised that as a bare "404 API Error". It now raises a PermitApiError, the type it raised before, whose message names the route and says only the container PDP serves it; the error's details hold the message too. A 404 counts as the cloud PDP's when the pdp setting is the cloud PDP's host, or when its body is empty. A container PDP's own 404s have a JSON body, and so do the API's 404s its /facts routes pass on, so a missing tenant through a container PDP still raises PermitNotFoundError with its usual message. PermitApiError takes an optional keyword-only message for this. The new tests send PATCH /facts/users/{user_id}, so its "untested" API coverage allowlist entry goes. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage_allowlist.json | 8 - permit/api/base.py | 53 ++- permit/exceptions.py | 15 +- permit/pdp_api/base.py | 4 +- permit/utils/cloud_pdp.py | 61 +++- tests/test_container_pdp_only_offline.py | 382 +++++++++++++++++++- 6 files changed, 499 insertions(+), 24 deletions(-) diff --git a/.github/scripts/api_coverage_allowlist.json b/.github/scripts/api_coverage_allowlist.json index 9b32c0ee..dd2b7033 100644 --- a/.github/scripts/api_coverage_allowlist.json +++ b/.github/scripts/api_coverage_allowlist.json @@ -1696,14 +1696,6 @@ "ticket": "PER-16177", "reason": "Called by permit.api.users.sync() with proxy_facts_via_pdp; no offline test sends this request yet." }, - { - "api": "pdp", - "operation": "PATCH /facts/users/{user_id}", - "stage": "GA", - "status": "untested", - "ticket": "PER-16177", - "reason": "Called by permit.api.users.update() with proxy_facts_via_pdp; no offline test sends this request yet." - }, { "api": "pdp", "operation": "DELETE /facts/users/{user_id}/roles", diff --git a/permit/api/base.py b/permit/api/base.py index cb34c96e..31e0720d 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -1,10 +1,15 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast, overload -from aiohttp import ClientTimeout +from aiohttp import ClientResponse, ClientTimeout from multidict import CIMultiDict from yarl import URL from permit.api.encoders import jsonable_encoder +from permit.utils.cloud_pdp import ( + USE_A_CONTAINER_PDP_FOR_FACTS, + container_pdp_only_message, + is_cloud_pdp_route_not_found, +) from permit.utils.http_sessions import LoopSessions from permit.utils.pydantic_version import PYDANTIC_VERSION from permit.utils.sdk_logger import sdk_logger @@ -20,7 +25,12 @@ from permit.api.context import API_ACCESS_LEVELS, ApiContextLevel, ApiKeyAccessLevel from permit.api.models import APIKeyScopeRead from permit.config import PermitConfig -from permit.exceptions import PermitContextError, handle_api_error, handle_client_error +from permit.exceptions import ( + PermitApiError, + PermitContextError, + handle_api_error, + handle_client_error, +) # Whatever `parse_obj_as` can build: a model, or e.g. `list[Model]` for list endpoints. TModel = TypeVar("TModel") @@ -97,6 +107,11 @@ class SimpleHttpClient: ``client_config["timeout"]``. sessions: The sessions to send the requests through. Without them, the client has sessions of its own. + container_pdp_advice: For a client of a PDP route that only the container PDP + serves: what to do instead, said by the error it raises when the cloud PDP + answers 404 for the route (see ``is_cloud_pdp_route_not_found``). The error is a + ``PermitApiError`` that names the route and says it needs the container PDP. + None, the default, for a route that every PDP, or the API, serves. Raises: TypeError: If ``client_config`` has a key other than those above. @@ -109,6 +124,7 @@ def __init__( timeout: int | None = None, *, sessions: LoopSessions | None = None, + container_pdp_advice: str | None = None, ) -> None: unsupported = sorted(set(client_config) - _CLIENT_CONFIG_KEYS) if unsupported: @@ -124,11 +140,33 @@ def __init__( ) self._base_url = base_url self._sessions = sessions if sessions is not None else LoopSessions() + self._container_pdp_advice = container_pdp_advice def _use_sessions(self, sessions: LoopSessions) -> None: """Send the requests through ``sessions`` from now on.""" self._sessions = sessions + async def _raise_for_status(self, response: ClientResponse) -> None: + """Raise the SDK's error for an error ``response``, as ``handle_api_error`` does. + + For a client of a route only the container PDP serves, the cloud PDP's 404 for the + route is raised as a ``PermitApiError`` whose message names the route and says it + needs the container PDP. The error's ``details`` hold the response's text, as for any + body that is not JSON, and that message. + """ + if self._container_pdp_advice is not None and await is_cloud_pdp_route_not_found( + response, str(self._server_url) + ): + message = container_pdp_only_message( + "The SDK", + f"{response.method} {response.url.path}", + str(self._server_url), + self._container_pdp_advice, + ) + text = await response.text(errors="replace") + raise PermitApiError(response, {"details": text, "message": message}, message=message) + await handle_api_error(response) + def _request_url(self, url: str) -> URL: """``url`` resolved against the client's ``base_url``, as an aiohttp session does it. @@ -190,7 +228,7 @@ async def get(self, url: str, model: type[TModel], **kwargs: Any) -> TModel: client = await self._sessions.current() self._log_request(url, "GET") async with client.get(target, **self._request_options(kwargs)) as response: - await handle_api_error(response) + await self._raise_for_status(response) self._log_response(url, "GET", response.status) data = await response.json() return parse_obj_as(model, data) @@ -211,7 +249,7 @@ async def post( async with client.post( target, json=self._prepare_json(json), **self._request_options(kwargs) ) as response: - await handle_api_error(response) + await self._raise_for_status(response) self._log_response(url, "POST", response.status) data = await response.json() return parse_obj_as(model, data) @@ -232,7 +270,7 @@ async def put( async with client.put( target, json=self._prepare_json(json), **self._request_options(kwargs) ) as response: - await handle_api_error(response) + await self._raise_for_status(response) self._log_response(url, "PUT", response.status) data = await response.json() return parse_obj_as(model, data) @@ -253,7 +291,7 @@ async def patch( async with client.patch( target, json=self._prepare_json(json), **self._request_options(kwargs) ) as response: - await handle_api_error(response) + await self._raise_for_status(response) self._log_response(url, "PATCH", response.status) data = await response.json() return parse_obj_as(model, data) @@ -292,7 +330,7 @@ async def delete( async with client.delete( target, json=self._prepare_json(json), **self._request_options(kwargs) ) as response: - await handle_api_error(response) + await self._raise_for_status(response) self._log_response(url, "DELETE", response.status) if model is None: return None @@ -346,6 +384,7 @@ def _build_http_client( base_url=endpoint_url, timeout=self.config.api_timeout, sessions=self._sessions, + container_pdp_advice=USE_A_CONTAINER_PDP_FOR_FACTS if use_pdp else None, ) async def _set_context_from_api_key(self) -> None: diff --git a/permit/exceptions.py b/permit/exceptions.py index 319b1dac..e5a4b549 100644 --- a/permit/exceptions.py +++ b/permit/exceptions.py @@ -77,18 +77,31 @@ class PermitContextChangeError(PermitError): class PermitApiError(PermitError): - """Wraps an error HTTP Response that occurred during a Permit REST API request.""" + """Wraps an error HTTP Response that occurred during a Permit REST API request. + + Args: + response: The error response. + body: The response's JSON body, or for a body that is not JSON, + ``{"details": }``. + message: The error's message, in place of the one it builds from the status code + and the body. + """ def __init__( self, response: aiohttp.ClientResponse, body: dict[str, Any] | None = None, + *, + message: str | None = None, ) -> None: super().__init__() self._response = response self._body = body + self._message = message def _get_message(self) -> str: + if self._message is not None: + return self._message return f"{self.status_code} API Error: {self.details}" def __str__(self) -> str: diff --git a/permit/pdp_api/base.py b/permit/pdp_api/base.py index 7e2edd07..7b32fd7c 100644 --- a/permit/pdp_api/base.py +++ b/permit/pdp_api/base.py @@ -1,12 +1,13 @@ from permit import PermitConfig from permit.api.base import ClientConfig, SimpleHttpClient, pagination_params +from permit.utils.cloud_pdp import USE_A_CONTAINER_PDP from permit.utils.http_sessions import LoopSessions __all__ = ["BasePdpPermitApi", "ClientConfig", "pagination_params"] class BasePdpPermitApi: - """The base class for Permit APIs.""" + """The base class of the APIs the PDP serves. Only the container PDP serves them.""" def __init__(self, config: PermitConfig) -> None: """Initialize a BasePermitApi. @@ -37,4 +38,5 @@ def _build_http_client(self, endpoint_url: str = "") -> SimpleHttpClient: # call used aiohttp's default timeout instead of the configured one. timeout=self.config.pdp_timeout, sessions=self._sessions, + container_pdp_advice=USE_A_CONTAINER_PDP, ) diff --git a/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py index c3823e89..0fde5ba0 100644 --- a/permit/utils/cloud_pdp.py +++ b/permit/utils/cloud_pdp.py @@ -1,13 +1,70 @@ -"""The routes only a container PDP serves, and the hosted cloud PDP does not. +"""Tell the hosted cloud PDP from a container PDP, for the routes only a container PDP serves. The cloud PDP serves the decision routes (``/allowed``, ``/allowed/bulk``, ``/authorized_users``, ``/user-permissions`` and AuthZEN) and ``/health``. A container PDP -serves those too, and also ``/user-tenants``. +serves those too, and also the routes the cloud PDP does not: ``/user-tenants``, the +``/local`` routes of ``permit.pdp_api``, and the ``/facts`` routes that +``proxy_facts_via_pdp`` sends the facts methods of ``permit.api`` to. + +The cloud PDP answers a route it does not serve with a 404 that has an empty body. A +container PDP's own 404 has a JSON body, and so does the API's 404 that its ``/facts`` +routes pass on, such as for a tenant that does not exist. """ +from http import HTTPStatus + +import aiohttp +from yarl import URL + +CLOUD_PDP_HOST = "cloudpdp.api.permit.io" +"""The host of the hosted cloud PDP.""" + SETUP_PDP_DOCS_LINK = "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" USE_A_CONTAINER_PDP = "Point the SDK's `pdp` setting at a container PDP to use it." +USE_A_CONTAINER_PDP_FOR_FACTS = ( + "Point the SDK's `pdp` setting at a container PDP to use it, or turn proxy_facts_via_pdp " + "off to send facts to the Permit REST API." +) + + +def is_cloud_pdp(pdp_url: str) -> bool: + """Whether ``pdp_url`` is the address of the hosted cloud PDP. + + Args: + pdp_url: A PDP address, as the SDK's ``pdp`` setting takes it. + + Returns: + True if the URL's host is the cloud PDP's, whatever its scheme, port or path. + """ + try: + host = URL(pdp_url).host + except ValueError: + return False + return host == CLOUD_PDP_HOST + + +async def is_cloud_pdp_route_not_found(response: aiohttp.ClientResponse, pdp_url: str) -> bool: + """Whether a PDP's ``response`` is the cloud PDP's 404 for a route it does not serve. + + It is when the status is 404 and either ``pdp_url`` is the cloud PDP's address or the + body is empty, which is how the cloud PDP answers such a route. A 404 with a body comes + from a container PDP, or from the API through a container PDP's ``/facts`` routes, and + is a real "not found". + + Args: + response: The PDP's response to a request for a route only the container PDP serves. + pdp_url: The address of the PDP the request was sent to, as the SDK's ``pdp`` + setting gives it. + + Returns: + True if the response is the cloud PDP's 404 for a route it does not serve. + """ + if response.status != HTTPStatus.NOT_FOUND: + return False + if is_cloud_pdp(pdp_url): + return True + return not (await response.read()).strip() def container_pdp_only_message( diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index 0016b285..8a6ef467 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -1,8 +1,13 @@ """Offline tests for the routes only the container PDP serves (PER-16340). -The hosted cloud PDP serves the decision routes and ``/health``. It answers 404 for -``/user-tenants``, which ``get_user_tenants()`` calls, and the SDK raises that 404 as an -error that names the route and says it needs the container PDP. +The hosted cloud PDP serves the decision routes and ``/health``. It answers 404, with an +empty body, for the routes it does not serve: ``/user-tenants``, which +``get_user_tenants()`` calls; the ``/local`` routes of ``permit.pdp_api``; and the +``/facts`` routes that the facts methods of ``permit.api`` call with +``proxy_facts_via_pdp`` on. The SDK raises that 404 as an error that names the route and +says it needs the container PDP, and keeps its usual error for a 404 that is a real "not +found": a container PDP's, which has a JSON body, or the API's, which a container PDP's +``/facts`` routes pass on. Each call goes through the async and the blocking client, each closed once the call returns. Every request is served by a local ``pytest_httpserver`` and the API context is @@ -11,18 +16,37 @@ import asyncio import inspect +import socket from operator import attrgetter +from typing import Any, NamedTuple import pytest from pytest_httpserver import HTTPServer +from werkzeug import Request -from permit import Permit, PermitConnectionError +from permit import ErrorCode, Permit, PermitConnectionError from permit.config import PermitConfig +from permit.exceptions import PermitApiError, PermitNotFoundError from permit.sync import Permit as SyncPermit -from tests.utils import Call, call +from tests.utils import Call, call, sent FLAVOURS = ["async", "sync"] +CLOUD_PDP_HOST = "cloudpdp.api.permit.io" DOCS_LINK = "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" +USE_A_CONTAINER_PDP = "Point the SDK's `pdp` setting at a container PDP to use it." +USE_A_CONTAINER_PDP_FOR_FACTS = ( + "Point the SDK's `pdp` setting at a container PDP to use it, or turn proxy_facts_via_pdp " + "off to send facts to the Permit REST API." +) + +# The headers the SDK sets. The wait-for-sync ones are listed so that sending one shows. +HEADERS = ("Authorization", "Content-Type", "X-Wait-Timeout", "X-Timeout-Policy") +JSON_HEADERS: dict[str, str | None] = { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + "X-Wait-Timeout": None, + "X-Timeout-Policy": None, +} def invoke(config: PermitConfig, flavour: str, target: Call) -> object: @@ -40,6 +64,50 @@ async def call_awaiting() -> object: return result +def sent_headers(request: Request) -> dict[str, str | None]: + return {name: request.headers.get(name) for name in HEADERS} + + +def container_pdp_only(route: str, pdp_url: str, advice: str) -> str: + """The message of the error for the cloud PDP's 404 for ``route``.""" + return ( + f"The SDK got status code 404 from the PDP at {pdp_url}: only the container PDP " + f"serves {route}, and the cloud PDP does not.\n" + f"{advice}\n" + f"Read more about setting up the PDP at {DOCS_LINK}" + ) + + +@pytest.fixture +def pdp_server(httpserver_ipv4: HTTPServer) -> HTTPServer: + """A server of its own for the PDP, so a request reaching it is told from one to the API.""" + return httpserver_ipv4 + + +@pytest.fixture +def split_config(config: PermitConfig, pdp_server: HTTPServer) -> PermitConfig: + """The offline config with the API on ``httpserver`` and the PDP on ``pdp_server``.""" + config.pdp = pdp_server.url_for("").rstrip("/") + return config + + +@pytest.fixture +def cloud_pdp_url(pdp_server: HTTPServer, monkeypatch: pytest.MonkeyPatch) -> str: + """A cloud PDP address whose host resolves to ``pdp_server``, on its port.""" + resolve = socket.getaddrinfo + + def resolve_the_cloud_pdp_locally( + host: bytes | str | None, *args: Any, **kwargs: Any + ) -> list[Any]: + return resolve("127.0.0.1" if host == CLOUD_PDP_HOST else host, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", resolve_the_cloud_pdp_locally) + return f"http://{CLOUD_PDP_HOST}:{pdp_server.port}" + + +# --- get_user_tenants() --------------------------------------------------------------- + + @pytest.mark.parametrize("flavour", FLAVOURS) def test_get_user_tenants_names_the_route_and_asks_for_a_container_pdp( httpserver: HTTPServer, config: PermitConfig, flavour: str @@ -55,3 +123,307 @@ def test_get_user_tenants_names_the_route_and_asks_for_a_container_pdp( "Point the SDK's `pdp` setting at a container PDP to use it.\n" f"Read more about setting up the PDP at {DOCS_LINK}" ) + + +# --- facts through the PDP ------------------------------------------------------------ + + +class FactsCase(NamedTuple): + """A facts method's call, and the one request it sends to the PDP's /facts routes.""" + + call: Call + method: str + path: str + query: list[tuple[str, str]] + body: Any + + +USER = {"key": "alice"} +TENANT = {"key": "t1", "name": "T1"} +ASSIGNMENT = {"user": "alice", "role": "viewer", "tenant": "t1"} +INSTANCE = {"key": "doc-1", "resource": "document", "tenant": "t1"} +TUPLE = {"subject": "folder:f1", "relation": "parent", "object": "document:doc-1"} +PAGE = [("page", "1"), ("per_page", "100")] + +# Facts methods of each API class, over each HTTP verb. The API coverage report fails on a +# request that neither the PDP's spec nor an `sdk_only` entry of +# .github/scripts/api_coverage_allowlist.json accounts for, so these send only such requests. +FACTS_CASES = { + "users.create": FactsCase(call("api.users.create", USER), "POST", "/facts/users", [], USER), + "users.update": FactsCase( + call("api.users.update", "alice", {"first_name": "Alice"}), + "PATCH", + "/facts/users/alice", + [], + {"first_name": "Alice"}, + ), + "users.assign_role": FactsCase( + call("api.users.assign_role", ASSIGNMENT), + "POST", + "/facts/users/alice/roles", + [], + {"role": "viewer", "tenant": "t1"}, + ), + "users.bulk_create": FactsCase( + call("api.users.bulk_create", [USER]), + "POST", + "/facts/bulk/users", + [], + {"operations": [USER]}, + ), + "tenants.create": FactsCase( + call("api.tenants.create", TENANT), "POST", "/facts/tenants", [], TENANT + ), + "tenants.delete": FactsCase( + call("api.tenants.delete", "t1"), "DELETE", "/facts/tenants/t1", [], None + ), + "tenants.delete_tenant_user": FactsCase( + call("api.tenants.delete_tenant_user", "t1", "alice"), + "DELETE", + "/facts/tenants/t1/users/alice", + [], + None, + ), + "tenants.bulk_create": FactsCase( + call("api.tenants.bulk_create", [TENANT]), + "POST", + "/facts/bulk/tenants", + [], + {"operations": [TENANT]}, + ), + "role_assignments.assign": FactsCase( + call("api.role_assignments.assign", ASSIGNMENT), + "POST", + "/facts/role_assignments", + [], + ASSIGNMENT, + ), + "role_assignments.list_detailed": FactsCase( + call("api.role_assignments.list_detailed", user_key="alice"), + "GET", + "/facts/role_assignments/detailed", + [*PAGE, ("user", "alice")], + None, + ), + "resource_instances.create": FactsCase( + call("api.resource_instances.create", INSTANCE), + "POST", + "/facts/resource_instances", + [], + INSTANCE, + ), + "resource_instances.bulk_replace": FactsCase( + call("api.resource_instances.bulk_replace", [INSTANCE]), + "PUT", + "/facts/bulk/resource_instances", + [], + {"operations": [INSTANCE]}, + ), + "relationship_tuples.create": FactsCase( + call("api.relationship_tuples.create", TUPLE), + "POST", + "/facts/relationship_tuples", + [], + TUPLE, + ), + "relationship_tuples.list_detailed": FactsCase( + call("api.relationship_tuples.list_detailed"), + "GET", + "/facts/relationship_tuples/detailed", + PAGE, + None, + ), +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("case", FACTS_CASES.values(), ids=FACTS_CASES.keys()) +def test_a_facts_method_raises_the_cloud_pdp_404_as_an_api_error_that_asks_for_a_container_pdp( + *, + httpserver: HTTPServer, + pdp_server: HTTPServer, + split_config: PermitConfig, + case: FactsCase, + flavour: str, +) -> None: + split_config.proxy_facts_via_pdp = True + pdp_server.expect_request(case.path, method=case.method).respond_with_data("", status=404) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, case.call) + + message = container_pdp_only( + f"{case.method} {case.path}", split_config.pdp, USE_A_CONTAINER_PDP_FOR_FACTS + ) + assert type(raised.value) is PermitApiError + assert str(raised.value) == message + assert raised.value.message == message + assert raised.value.details == {"details": "", "message": message} + assert raised.value.status_code == 404 + assert [sent(request) for request, _ in pdp_server.log] == [ + {"method": case.method, "path": case.path, "query": case.query, "body": case.body} + ] + assert [sent_headers(request) for request, _ in pdp_server.log] == [JSON_HEADERS] + assert httpserver.log == [] + + +NOT_FOUND_DETAILS = { + "id": "request-1", + "title": "The requested data was not found", + "error_code": "NOT_FOUND", + "message": "Tenant with key 't1' was not found.", +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + "case", + [FACTS_CASES["tenants.delete"], FACTS_CASES["users.update"]], + ids=["tenants.delete", "users.update"], +) +def test_the_apis_404_through_a_container_pdp_keeps_its_not_found_error( + *, + httpserver: HTTPServer, + pdp_server: HTTPServer, + split_config: PermitConfig, + case: FactsCase, + flavour: str, +) -> None: + """A container PDP's /facts routes pass on the API's 404 for an object that is missing.""" + split_config.proxy_facts_via_pdp = True + pdp_server.expect_request(case.path, method=case.method).respond_with_json( + NOT_FOUND_DETAILS, status=404 + ) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, case.call) + + assert type(raised.value) is PermitNotFoundError + assert str(raised.value) == ( + f"The requested data was not found ({ErrorCode.NOT_FOUND})\n" + "Tenant with key 't1' was not found.\n" + "For more information: https://permit-io.slack.com/ssb/redirect (Request ID: request-1)" + ) + assert raised.value.details == NOT_FOUND_DETAILS + assert len(pdp_server.log) == 1 + assert httpserver.log == [] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + ("proxy_facts_via_pdp", "path", "target"), + [ + (True, "/facts/users", call("api.users.create", USER)), + (False, "/local/role_assignments", call("pdp_api.role_assignments.list")), + ], + ids=["facts", "pdp_api"], +) +def test_a_container_pdps_own_404_keeps_the_api_error_it_raised( + *, + pdp_server: HTTPServer, + split_config: PermitConfig, + proxy_facts_via_pdp: bool, + path: str, + target: Call, + flavour: str, +) -> None: + """A container PDP answers a route it does not serve with a JSON 404.""" + split_config.proxy_facts_via_pdp = proxy_facts_via_pdp + pdp_server.expect_request(path).respond_with_json({"detail": "Not Found"}, status=404) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, target) + + assert type(raised.value) is PermitApiError + assert str(raised.value) == "404 API Error: {'detail': 'Not Found'}" + assert raised.value.details == {"detail": "Not Found"} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_the_apis_empty_404_keeps_its_api_error_with_proxy_facts_via_pdp_off( + httpserver: HTTPServer, pdp_server: HTTPServer, split_config: PermitConfig, flavour: str +) -> None: + """Only the PDP's container-only routes read an empty 404 as the cloud PDP's.""" + path = "/v2/facts/test-project/test-env/users" + httpserver.expect_request(path, method="POST").respond_with_data("", status=404) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, call("api.users.create", USER)) + + assert type(raised.value) is PermitApiError + assert str(raised.value) == "404 API Error: {'details': ''}" + assert [sent(request) for request, _ in httpserver.log] == [ + {"method": "POST", "path": path, "query": [], "body": USER} + ] + assert pdp_server.log == [] + + +# --- permit.pdp_api ------------------------------------------------------------------- + + +LOCAL_ROLE_ASSIGNMENTS = "/local/role_assignments" + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_pdp_api_raises_the_cloud_pdp_404_as_an_api_error_that_asks_for_a_container_pdp( + httpserver: HTTPServer, pdp_server: HTTPServer, split_config: PermitConfig, flavour: str +) -> None: + pdp_server.expect_request(LOCAL_ROLE_ASSIGNMENTS, method="GET").respond_with_data( + "", status=404 + ) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, call("pdp_api.role_assignments.list", user_key="alice")) + + message = container_pdp_only( + f"GET {LOCAL_ROLE_ASSIGNMENTS}", split_config.pdp, USE_A_CONTAINER_PDP + ) + assert type(raised.value) is PermitApiError + assert str(raised.value) == message + assert raised.value.details == {"details": "", "message": message} + assert [sent(request) for request, _ in pdp_server.log] == [ + { + "method": "GET", + "path": LOCAL_ROLE_ASSIGNMENTS, + "query": [*PAGE, ("user", "alice")], + "body": None, + } + ] + assert [sent_headers(request) for request, _ in pdp_server.log] == [JSON_HEADERS] + assert httpserver.log == [] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("status", [401, 500]) +def test_another_empty_error_status_keeps_the_api_error_it_raised( + pdp_server: HTTPServer, split_config: PermitConfig, status: int, flavour: str +) -> None: + pdp_server.expect_request(LOCAL_ROLE_ASSIGNMENTS).respond_with_data("", status=status) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, call("pdp_api.role_assignments.list")) + + assert type(raised.value) is PermitApiError + assert str(raised.value) == f"{status} API Error: {{'details': ''}}" + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_any_404_from_the_cloud_pdps_host_asks_for_a_container_pdp( + pdp_server: HTTPServer, split_config: PermitConfig, cloud_pdp_url: str, flavour: str +) -> None: + """At the cloud PDP's address, a 404 with a body counts as the cloud PDP's too.""" + split_config.pdp = cloud_pdp_url + pdp_server.expect_request(LOCAL_ROLE_ASSIGNMENTS).respond_with_data( + '{"detail": "Not Found"}', status=404, content_type="application/json" + ) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, call("pdp_api.role_assignments.list")) + + message = container_pdp_only( + f"GET {LOCAL_ROLE_ASSIGNMENTS}", cloud_pdp_url, USE_A_CONTAINER_PDP + ) + assert type(raised.value) is PermitApiError + assert str(raised.value) == message + assert raised.value.details == {"details": '{"detail": "Not Found"}', "message": message} + assert len(pdp_server.log) == 1 From 2958daccaeebaefaf4596b1ae849a1e3c564ee53 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:25:02 +0300 Subject: [PATCH 04/20] Say "container PDP only" in the affected methods' docstrings Every facts method that proxy_facts_via_pdp sends to the PDP now says it is container PDP only with the proxy on, and that the cloud PDP's 404 is raised as a PermitApiError that says so. pdp_api's role_assignments.list() and get_user_tenants() say it plainly, and the pdp_api entry points say the cloud PDP serves none of its routes. A test pins the list of facts methods against the public methods of the facts APIs, and checks each docstring on both clients. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- permit/_sync_types.pyi | 195 ++++++++++++++++++++++- permit/api/relationship_tuples.py | 24 +++ permit/api/resource_instances.py | 40 +++++ permit/api/role_assignments.py | 24 +++ permit/api/tenants.py | 44 +++++ permit/api/users.py | 56 +++++++ permit/enforcement/enforcer.py | 4 +- permit/pdp_api/pdp_api_client.py | 2 +- permit/pdp_api/role_assignments.py | 3 + permit/permit.py | 6 +- permit/sync.py | 6 +- tests/test_container_pdp_only_offline.py | 129 +++++++++++++++ 12 files changed, 524 insertions(+), 9 deletions(-) diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index d0b47ac3..c5ccbb01 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -946,6 +946,10 @@ class SyncRelationshipTuplesApi(BasePermitApi): ) -> list[RelationshipTupleRead]: """Retrieves a list of relationship tuples based on the specified filters. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -984,6 +988,10 @@ class SyncRelationshipTuplesApi(BasePermitApi): Needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch, starting at 1 (default: 1). per_page: How many items to fetch per page, at most 100 (default: 100). @@ -1009,6 +1017,10 @@ class SyncRelationshipTuplesApi(BasePermitApi): The tuple states that a relationship (of type: relation) exists between two resource instances: the subject and the object. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuple_data: The relationship tuple to create. @@ -1023,6 +1035,10 @@ class SyncRelationshipTuplesApi(BasePermitApi): def delete(self, tuple_data: ModelInput[RelationshipTupleDelete]) -> None: """Removes a relationship tuple. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuple_data: The relationship tuple to delete. @@ -1036,6 +1052,10 @@ class SyncRelationshipTuplesApi(BasePermitApi): ) -> RelationshipTupleCreateBulkOperationResult: """Creates multiple relationship tuples at once using the provided tuple data. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuples: The relationship tuples to create. Each tuple object is of type RelationshipTupleCreate and is essentially @@ -1062,6 +1082,10 @@ class SyncRelationshipTuplesApi(BasePermitApi): ) -> RelationshipTupleDeleteBulkOperationResult: """Deletes multiple relationship tuples at once using the provided tuple data. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuples: The relationship tuples to delete. Each tuple object is of type RelationshipTupleDelete and is essentially @@ -1447,6 +1471,10 @@ class SyncResourceInstancesApi(BasePermitApi): ) -> list[ResourceInstanceRead]: """Retrieves a list of resource instances. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -1485,6 +1513,10 @@ class SyncResourceInstancesApi(BasePermitApi): Needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch, starting at 1 (default: 1). per_page: How many items to fetch per page, at most 100 (default: 100). @@ -1503,6 +1535,10 @@ class SyncResourceInstancesApi(BasePermitApi): def get(self, instance_key: str) -> ResourceInstanceRead: """Retrieves a resource instance by its identity. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The resource instance identity. Either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key @@ -1521,6 +1557,10 @@ class SyncResourceInstancesApi(BasePermitApi): Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The resource instance identity. Either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key @@ -1539,6 +1579,10 @@ class SyncResourceInstancesApi(BasePermitApi): Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_id: The ID of the resource instance. @@ -1553,6 +1597,10 @@ class SyncResourceInstancesApi(BasePermitApi): def create(self, instance_data: ModelInput[ResourceInstanceCreate]) -> ResourceInstanceRead: """Creates a new resource instance. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_data: The data for the new resource instance. @@ -1569,6 +1617,10 @@ class SyncResourceInstancesApi(BasePermitApi): ) -> ResourceInstanceRead: """Updates a resource instance. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The resource instance identity. Either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key @@ -1586,6 +1638,10 @@ class SyncResourceInstancesApi(BasePermitApi): def delete(self, instance_key: str) -> None: """Deletes a resource instance. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The identity of the resource instance to delete. Either `resource_type:instance_key` @@ -1608,6 +1664,10 @@ class SyncResourceInstancesApi(BasePermitApi): If the resource instance exists - replaces it. Otherwise creates previously non-existing resource instances. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: resource_instances: The resource instances to create/replace. @@ -1624,6 +1684,10 @@ class SyncResourceInstancesApi(BasePermitApi): ) -> ResourceInstanceDeleteBulkOperationResult: """Deletes resource instances in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: resource_instances: The resource instance identities to delete. Each identity can be either `resource_type:instance_key` (like Repository:react) or the @@ -2086,6 +2150,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: if specified, only role granted to this user will be fetched. role_key: if specified, only assignments of this role will be fetched. @@ -2134,6 +2202,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: if specified, only roles granted to this user, or to any of these users, will be fetched. @@ -2161,6 +2233,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: """Assigns a role to a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: assignment: The role assignment details. @@ -2175,6 +2251,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): def unassign(self, unassignment: ModelInput[RoleAssignmentRemove]) -> None: """Unassigns a role from a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: unassignment: The role unassignment details. @@ -2190,6 +2270,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): Each role assignment is a tuple of (user, role, tenant). + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: assignments: The role assignments to be performed in bulk. @@ -2208,6 +2292,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): Each role to unassign is a tuple of (user, role, tenant). + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: unassignments: The role unassignments to be performed in bulk. @@ -2361,6 +2449,10 @@ class SyncTenantsApi(BasePermitApi): def list(self, page: int = 1, per_page: int = 100) -> list[TenantRead]: """Retrieves a list of tenants. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -2378,6 +2470,10 @@ class SyncTenantsApi(BasePermitApi): ) -> PaginatedResultUserRead: """Retrieves a list of users for a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. page: The page number to fetch (default: 1). @@ -2441,6 +2537,10 @@ class SyncTenantsApi(BasePermitApi): def get(self, tenant_key: str) -> TenantRead: """Retrieves a tenant by its key. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. @@ -2457,6 +2557,10 @@ class SyncTenantsApi(BasePermitApi): Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. @@ -2473,6 +2577,10 @@ class SyncTenantsApi(BasePermitApi): Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_id: The ID of the tenant. @@ -2487,6 +2595,10 @@ class SyncTenantsApi(BasePermitApi): def create(self, tenant_data: ModelInput[TenantCreate]) -> TenantRead: """Creates a new tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_data: The data for the new tenant. @@ -2501,6 +2613,10 @@ class SyncTenantsApi(BasePermitApi): def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) -> TenantRead: """Updates a tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. tenant_data: The updated data for the tenant. @@ -2516,6 +2632,10 @@ class SyncTenantsApi(BasePermitApi): def delete(self, tenant_key: str) -> None: """Deletes a tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant to delete. @@ -2539,6 +2659,10 @@ class SyncTenantsApi(BasePermitApi): on resource instances, so ``create_user()`` can create a user with that key again. Otherwise the user stays a member of the tenant, with no tenant-level role there. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. user_key: The key of the user whose roles in the tenant to remove. @@ -2552,6 +2676,10 @@ class SyncTenantsApi(BasePermitApi): def bulk_create(self, tenants: ModelListInput[TenantCreate]) -> TenantCreateBulkOperationResult: """Creates tenants in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenants: The tenants to create @@ -2566,6 +2694,10 @@ class SyncTenantsApi(BasePermitApi): def bulk_delete(self, tenants: builtins.list[str]) -> TenantDeleteBulkOperationResult: """Deletes tenants in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenants: The tenants identities to delete. Each identity can be either the tenant key or the tenant id. @@ -2663,6 +2795,10 @@ class SyncUsersApi(BasePermitApi): def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultUserRead: """Retrieves a list of users. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -2678,6 +2814,10 @@ class SyncUsersApi(BasePermitApi): def get(self, user_key: str) -> UserRead: """Retrieves a user by its key. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user. @@ -2694,6 +2834,10 @@ class SyncUsersApi(BasePermitApi): Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user. @@ -2710,6 +2854,10 @@ class SyncUsersApi(BasePermitApi): Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_id: The ID of the user. @@ -2724,6 +2872,10 @@ class SyncUsersApi(BasePermitApi): def create(self, user_data: ModelInput[UserCreate]) -> UserRead: """Creates a new user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_data: The data for the new user. @@ -2738,6 +2890,10 @@ class SyncUsersApi(BasePermitApi): def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> UserRead: """Updates a user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user. user_data: The updated data for the user. @@ -2753,6 +2909,10 @@ class SyncUsersApi(BasePermitApi): def sync(self, user: _UserSyncInput) -> UserRead: """Synchronizes user data by creating or updating a user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user: The data of the user to be synchronized. @@ -2767,6 +2927,10 @@ class SyncUsersApi(BasePermitApi): def delete(self, user_key: str) -> None: """Deletes a user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user to delete. @@ -2778,6 +2942,10 @@ class SyncUsersApi(BasePermitApi): def bulk_create(self, users: ModelListInput[UserCreate]) -> UserCreateBulkOperationResult: """Creates users in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: users: The users to create @@ -2795,6 +2963,10 @@ class SyncUsersApi(BasePermitApi): If the user exists - replaces it. Otherwise, creates previously non-existing users. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: users: The users to replace. @@ -2809,6 +2981,10 @@ class SyncUsersApi(BasePermitApi): def bulk_delete(self, users: builtins.list[str]) -> UserDeleteBulkOperationResult: """Deletes users in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: users: The users identities to delete. Each identity can be either the user key or the user id. @@ -2824,6 +3000,10 @@ class SyncUsersApi(BasePermitApi): def assign_role(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: """Assigns a role to a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: assignment: The role assignment details. @@ -2838,6 +3018,10 @@ class SyncUsersApi(BasePermitApi): def unassign_role(self, unassignment: ModelInput[RoleAssignmentRemove]) -> None: """Unassigns a role from a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: unassignment: The role unassignment details. @@ -2854,6 +3038,10 @@ class SyncUsersApi(BasePermitApi): The roles come from the given tenant if the tenant filter is provided, or from all tenants if it is not. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user: The key of the user. tenant: The key of the tenant. @@ -3013,8 +3201,8 @@ class SyncEnforcer: The PDP answers from the data it has synced, so a change made through the API shows up once the PDP has it. - Only the container PDP serves this query. The cloud PDP does not, and answers 404, - which this method raises as a ``PermitConnectionError`` that says so. + Container PDP only: the cloud PDP does not serve this query. It answers 404, which + this method raises as a ``PermitConnectionError`` that says so. Args: user: The user key, or a user dict with a ``key`` and optionally ``attributes``, @@ -3060,6 +3248,9 @@ class SyncPdpRoleAssignmentsApi(BasePdpPermitApi): ) -> list[RoleAssignment]: """Retrieves a list of role assignments based on the specified filters. + Container PDP only: the cloud PDP does not serve ``/local/role_assignments``. It + answers 404, which this method raises as a ``PermitApiError`` that says so. + Args: user_key: optional user filter, will only return role assignments granted to this user. role_key: optional role filter, will only return role assignments granting this role. diff --git a/permit/api/relationship_tuples.py b/permit/api/relationship_tuples.py index 74784311..336ee9d8 100644 --- a/permit/api/relationship_tuples.py +++ b/permit/api/relationship_tuples.py @@ -75,6 +75,10 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi ) -> list[RelationshipTupleRead]: """Retrieves a list of relationship tuples based on the specified filters. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -131,6 +135,10 @@ async def list_detailed( Needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch, starting at 1 (default: 1). per_page: How many items to fetch per page, at most 100 (default: 100). @@ -175,6 +183,10 @@ async def create( The tuple states that a relationship (of type: relation) exists between two resource instances: the subject and the object. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuple_data: The relationship tuple to create. @@ -196,6 +208,10 @@ async def create( async def delete(self, tuple_data: ModelInput[RelationshipTupleDelete]) -> None: """Removes a relationship tuple. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuple_data: The relationship tuple to delete. @@ -214,6 +230,10 @@ async def bulk_create( ) -> RelationshipTupleCreateBulkOperationResult: """Creates multiple relationship tuples at once using the provided tuple data. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuples: The relationship tuples to create. Each tuple object is of type RelationshipTupleCreate and is essentially @@ -249,6 +269,10 @@ async def bulk_delete( ) -> RelationshipTupleDeleteBulkOperationResult: """Deletes multiple relationship tuples at once using the provided tuple data. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tuples: The relationship tuples to delete. Each tuple object is of type RelationshipTupleDelete and is essentially diff --git a/permit/api/resource_instances.py b/permit/api/resource_instances.py index 82c7559f..82007494 100644 --- a/permit/api/resource_instances.py +++ b/permit/api/resource_instances.py @@ -87,6 +87,10 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi ) -> list[ResourceInstanceRead]: """Retrieves a list of resource instances. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -145,6 +149,10 @@ async def list_detailed( Needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch, starting at 1 (default: 1). per_page: How many items to fetch per page, at most 100 (default: 100). @@ -183,6 +191,10 @@ async def _get(self, instance_key: str) -> ResourceInstanceRead: async def get(self, instance_key: str) -> ResourceInstanceRead: """Retrieves a resource instance by its identity. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The resource instance identity. Either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key @@ -206,6 +218,10 @@ async def get_by_key(self, instance_key: str) -> ResourceInstanceRead: Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The resource instance identity. Either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key @@ -229,6 +245,10 @@ async def get_by_id(self, instance_id: str) -> ResourceInstanceRead: Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_id: The ID of the resource instance. @@ -250,6 +270,10 @@ async def create( ) -> ResourceInstanceRead: """Creates a new resource instance. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_data: The data for the new resource instance. @@ -273,6 +297,10 @@ async def update( ) -> ResourceInstanceRead: """Updates a resource instance. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The resource instance identity. Either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key @@ -299,6 +327,10 @@ async def update( async def delete(self, instance_key: str) -> None: """Deletes a resource instance. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: instance_key: The identity of the resource instance to delete. Either `resource_type:instance_key` @@ -326,6 +358,10 @@ async def bulk_replace( If the resource instance exists - replaces it. Otherwise creates previously non-existing resource instances. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: resource_instances: The resource instances to create/replace. @@ -351,6 +387,10 @@ async def bulk_delete( ) -> ResourceInstanceDeleteBulkOperationResult: """Deletes resource instances in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: resource_instances: The resource instance identities to delete. Each identity can be either `resource_type:instance_key` (like Repository:react) or the diff --git a/permit/api/role_assignments.py b/permit/api/role_assignments.py index c104d3bf..fe978724 100644 --- a/permit/api/role_assignments.py +++ b/permit/api/role_assignments.py @@ -91,6 +91,10 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: if specified, only role granted to this user will be fetched. role_key: if specified, only assignments of this role will be fetched. @@ -157,6 +161,10 @@ async def list_detailed( the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: if specified, only roles granted to this user, or to any of these users, will be fetched. @@ -202,6 +210,10 @@ async def list_detailed( async def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: """Assigns a role to a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: assignment: The role assignment details. @@ -221,6 +233,10 @@ async def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssi async def unassign(self, unassignment: ModelInput[RoleAssignmentRemove]) -> None: """Unassigns a role from a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: unassignment: The role unassignment details. @@ -241,6 +257,10 @@ async def bulk_assign( Each role assignment is a tuple of (user, role, tenant). + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: assignments: The role assignments to be performed in bulk. @@ -268,6 +288,10 @@ async def bulk_unassign( Each role to unassign is a tuple of (user, role, tenant). + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: unassignments: The role unassignments to be performed in bulk. diff --git a/permit/api/tenants.py b/permit/api/tenants.py index 3d5550d6..0845401d 100644 --- a/permit/api/tenants.py +++ b/permit/api/tenants.py @@ -58,6 +58,10 @@ def __bulk_operations(self) -> SimpleHttpClient: async def list(self, page: int = 1, per_page: int = 100) -> list[TenantRead]: """Retrieves a list of tenants. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -82,6 +86,10 @@ async def list_tenant_users( ) -> PaginatedResultUserRead: """Retrieves a list of users for a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. page: The page number to fetch (default: 1). @@ -168,6 +176,10 @@ async def _get(self, tenant_key: str) -> TenantRead: async def get(self, tenant_key: str) -> TenantRead: """Retrieves a tenant by its key. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. @@ -189,6 +201,10 @@ async def get_by_key(self, tenant_key: str) -> TenantRead: Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. @@ -210,6 +226,10 @@ async def get_by_id(self, tenant_id: str) -> TenantRead: Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_id: The ID of the tenant. @@ -229,6 +249,10 @@ async def get_by_id(self, tenant_id: str) -> TenantRead: async def create(self, tenant_data: ModelInput[TenantCreate]) -> TenantRead: """Creates a new tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_data: The data for the new tenant. @@ -248,6 +272,10 @@ async def create(self, tenant_data: ModelInput[TenantCreate]) -> TenantRead: async def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) -> TenantRead: """Updates a tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. tenant_data: The updated data for the tenant. @@ -268,6 +296,10 @@ async def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) - async def delete(self, tenant_key: str) -> None: """Deletes a tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant to delete. @@ -296,6 +328,10 @@ async def delete_tenant_user(self, tenant_key: str, user_key: str) -> None: on resource instances, so ``create_user()`` can create a user with that key again. Otherwise the user stays a member of the tenant, with no tenant-level role there. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenant_key: The key of the tenant. user_key: The key of the user whose roles in the tenant to remove. @@ -316,6 +352,10 @@ async def bulk_create( ) -> TenantCreateBulkOperationResult: """Creates tenants in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenants: The tenants to create @@ -339,6 +379,10 @@ async def bulk_create( async def bulk_delete(self, tenants: builtins.list[str]) -> TenantDeleteBulkOperationResult: """Deletes tenants in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: tenants: The tenants identities to delete. Each identity can be either the tenant key or the tenant id. diff --git a/permit/api/users.py b/permit/api/users.py index 47e5317f..bfdfb692 100644 --- a/permit/api/users.py +++ b/permit/api/users.py @@ -73,6 +73,10 @@ def __bulk_operations(self) -> SimpleHttpClient: async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultUserRead: """Retrieves a list of users. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -100,6 +104,10 @@ async def _get(self, user_key: str) -> UserRead: async def get(self, user_key: str) -> UserRead: """Retrieves a user by its key. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user. @@ -121,6 +129,10 @@ async def get_by_key(self, user_key: str) -> UserRead: Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user. @@ -142,6 +154,10 @@ async def get_by_id(self, user_id: str) -> UserRead: Alias for the get method. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_id: The ID of the user. @@ -161,6 +177,10 @@ async def get_by_id(self, user_id: str) -> UserRead: async def create(self, user_data: ModelInput[UserCreate]) -> UserRead: """Creates a new user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_data: The data for the new user. @@ -180,6 +200,10 @@ async def create(self, user_data: ModelInput[UserCreate]) -> UserRead: async def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> UserRead: """Updates a user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user. user_data: The updated data for the user. @@ -200,6 +224,10 @@ async def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> User async def sync(self, user: _UserSyncInput) -> UserRead: """Synchronizes user data by creating or updating a user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user: The data of the user to be synchronized. @@ -226,6 +254,10 @@ async def sync(self, user: _UserSyncInput) -> UserRead: async def delete(self, user_key: str) -> None: """Deletes a user. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user_key: The key of the user to delete. @@ -242,6 +274,10 @@ async def delete(self, user_key: str) -> None: async def bulk_create(self, users: ModelListInput[UserCreate]) -> UserCreateBulkOperationResult: """Creates users in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: users: The users to create @@ -270,6 +306,10 @@ async def bulk_replace( If the user exists - replaces it. Otherwise, creates previously non-existing users. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: users: The users to replace. @@ -293,6 +333,10 @@ async def bulk_replace( async def bulk_delete(self, users: builtins.list[str]) -> UserDeleteBulkOperationResult: """Deletes users in bulk. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: users: The users identities to delete. Each identity can be either the user key or the user id. @@ -317,6 +361,10 @@ async def bulk_delete(self, users: builtins.list[str]) -> UserDeleteBulkOperatio async def assign_role(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: """Assigns a role to a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: assignment: The role assignment details. @@ -342,6 +390,10 @@ async def assign_role(self, assignment: ModelInput[RoleAssignmentCreate]) -> Rol async def unassign_role(self, unassignment: ModelInput[RoleAssignmentRemove]) -> None: """Unassigns a role from a user in the scope of a given tenant. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: unassignment: The role unassignment details. @@ -372,6 +424,10 @@ async def get_assigned_roles( The roles come from the given tenant if the tenant filter is provided, or from all tenants if it is not. + Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It answers 404, which + this method raises as a ``PermitApiError`` that says so. + Args: user: The key of the user. tenant: The key of the tenant. diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index edeac61c..072a13da 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -581,8 +581,8 @@ async def get_user_tenants( The PDP answers from the data it has synced, so a change made through the API shows up once the PDP has it. - Only the container PDP serves this query. The cloud PDP does not, and answers 404, - which this method raises as a ``PermitConnectionError`` that says so. + Container PDP only: the cloud PDP does not serve this query. It answers 404, which + this method raises as a ``PermitConnectionError`` that says so. Args: user: The user key, or a user dict with a ``key`` and optionally ``attributes``, diff --git a/permit/pdp_api/pdp_api_client.py b/permit/pdp_api/pdp_api_client.py index 014cc4f3..4e2a0f55 100644 --- a/permit/pdp_api/pdp_api_client.py +++ b/permit/pdp_api/pdp_api_client.py @@ -20,7 +20,7 @@ class SyncRoleAssignmentsApi(RoleAssignmentsApi, metaclass=SyncClass): class PermitPdpApiClient: - """Entry point to the APIs served by the PDP itself.""" + """Entry point to the APIs served by the PDP itself, which only the container PDP serves.""" def __init__(self, config: PermitConfig) -> None: """Constructs a new instance of the PdpApiClient class with the specified SDK configuration. diff --git a/permit/pdp_api/role_assignments.py b/permit/pdp_api/role_assignments.py index 614acc26..8d0249ed 100644 --- a/permit/pdp_api/role_assignments.py +++ b/permit/pdp_api/role_assignments.py @@ -34,6 +34,9 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi ) -> list[RoleAssignment]: """Retrieves a list of role assignments based on the specified filters. + Container PDP only: the cloud PDP does not serve ``/local/role_assignments``. It + answers 404, which this method raises as a ``PermitApiError`` that says so. + Args: user_key: optional user filter, will only return role assignments granted to this user. role_key: optional role filter, will only return role assignments granting this role. diff --git a/permit/permit.py b/permit/permit.py index 1754b6db..ad754c0d 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -196,6 +196,8 @@ def elements(self) -> ElementsApi: def pdp_api(self) -> PermitPdpApiClient: """Access the Permit PDP API using this property. + Container PDP only: the cloud PDP serves none of its routes. + Usage example: permit = Permit(token="") @@ -357,8 +359,8 @@ async def get_user_tenants( The PDP answers from the data it has synced, so a change made through the API shows up once the PDP has it. - Only the container PDP serves this query. The cloud PDP does not, and answers 404, - which this method raises as a ``PermitConnectionError`` that says so. + Container PDP only: the cloud PDP does not serve this query. It answers 404, which + this method raises as a ``PermitConnectionError`` that says so. Args: user: The user key, or a user dict with a ``key`` and optionally ``attributes``, diff --git a/permit/sync.py b/permit/sync.py index ed3861b2..8c693340 100644 --- a/permit/sync.py +++ b/permit/sync.py @@ -152,6 +152,8 @@ def elements(self) -> SyncElementsApi: # type: ignore[override] def pdp_api(self) -> SyncPDPApi: """Access the Permit PDP API using this property. + Container PDP only: the cloud PDP serves none of its routes. + Usage example: permit = Permit(token="") permit.pdp_api.role_assignments(...) @@ -313,8 +315,8 @@ def get_user_tenants( # type: ignore[override] The PDP answers from the data it has synced, so a change made through the API shows up once the PDP has it. - Only the container PDP serves this query. The cloud PDP does not, and answers 404, - which this method raises as a ``PermitConnectionError`` that says so. + Container PDP only: the cloud PDP does not serve this query. It answers 404, which + this method raises as a ``PermitConnectionError`` that says so. Args: user: The user key, or a user dict with a ``key`` and optionally ``attributes``, diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index 8a6ef467..396b81fb 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -17,6 +17,7 @@ import asyncio import inspect import socket +from collections.abc import Iterator from operator import attrgetter from typing import Any, NamedTuple @@ -427,3 +428,131 @@ def test_any_404_from_the_cloud_pdps_host_asks_for_a_container_pdp( assert str(raised.value) == message assert raised.value.details == {"details": '{"detail": "Not Found"}', "message": message} assert len(pdp_server.log) == 1 + + +# --- docstrings ----------------------------------------------------------------------- + + +# With proxy_facts_via_pdp on, every public method of these APIs sends its request to the +# PDP's /facts routes, except tenants.create_user() and its deprecated alias add_user(), +# which always go to the Permit REST API. +FACTS_APIS = ("users", "tenants", "role_assignments", "resource_instances", "relationship_tuples") +API_ONLY = ("api.tenants.create_user", "api.tenants.add_user") +FACTS_METHODS = ( + *( + f"api.users.{name}" + for name in ( + "assign_role", + "bulk_create", + "bulk_delete", + "bulk_replace", + "create", + "delete", + "get", + "get_assigned_roles", + "get_by_id", + "get_by_key", + "list", + "sync", + "unassign_role", + "update", + ) + ), + *( + f"api.tenants.{name}" + for name in ( + "bulk_create", + "bulk_delete", + "create", + "delete", + "delete_tenant_user", + "get", + "get_by_id", + "get_by_key", + "list", + "list_tenant_users", + "update", + ) + ), + *( + f"api.role_assignments.{name}" + for name in ("assign", "bulk_assign", "bulk_unassign", "list", "list_detailed", "unassign") + ), + *( + f"api.resource_instances.{name}" + for name in ( + "bulk_delete", + "bulk_replace", + "create", + "delete", + "get", + "get_by_id", + "get_by_key", + "list", + "list_detailed", + "update", + ) + ), + *( + f"api.relationship_tuples.{name}" + for name in ("bulk_create", "bulk_delete", "create", "delete", "list", "list_detailed") + ), +) +FACTS_NOTE = ( + "Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the PDP's " + "``/facts`` routes, which the cloud PDP does not serve. It answers 404, which this method " + "raises as a ``PermitApiError`` that says so." +) +NOTES = { + **dict.fromkeys(FACTS_METHODS, FACTS_NOTE), + "pdp_api.role_assignments.list": ( + "Container PDP only: the cloud PDP does not serve ``/local/role_assignments``. It " + "answers 404, which this method raises as a ``PermitApiError`` that says so." + ), + "get_user_tenants": ( + "Container PDP only: the cloud PDP does not serve this query. It answers 404, which " + "this method raises as a ``PermitConnectionError`` that says so." + ), +} + + +@pytest.fixture(params=FLAVOURS) +def client(request: pytest.FixtureRequest, config: PermitConfig) -> Iterator[Permit]: + """An async or a blocking client, whose methods are only read, closed after the test.""" + if request.param == "async": + permit = Permit(config) + yield permit + asyncio.run(permit.close()) + else: + with SyncPermit(config) as blocking: + yield blocking + + +def docstring(client: Permit, path: str) -> str: + """The docstring of ``client.``, with each run of whitespace made one space.""" + return " ".join((inspect.getdoc(attrgetter(path)(client)) or "").split()) + + +def test_the_facts_methods_are_every_public_method_of_the_facts_apis_but_create_user( + client: Permit, +) -> None: + public = { + f"api.{api}.{name}" + for api in FACTS_APIS + for name in dir(getattr(client.api, api)) + if not name.startswith("_") and callable(getattr(getattr(client.api, api), name)) + } + + assert sorted(public - set(API_ONLY)) == sorted(FACTS_METHODS) + + +@pytest.mark.parametrize("path", NOTES.keys()) +def test_a_container_pdp_only_method_says_so_in_its_docstring(client: Permit, path: str) -> None: + assert NOTES[path] in docstring(client, path) + + +@pytest.mark.parametrize("path", API_ONLY) +def test_a_facts_api_method_that_always_goes_to_the_api_does_not_say_container_pdp_only( + client: Permit, path: str +) -> None: + assert "Container PDP only" not in docstring(client, path) From a5e0d818bc0eb355fd24eec9505c1cb031bb6433 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:19:17 +0300 Subject: [PATCH 05/20] Pin the route and sync headers of every proxied facts method With proxy_facts_via_pdp on, each method of the users, tenants, role assignments, resource instances and relationship tuples APIs now has an offline wire test that pins the route it sends its request to and that the request carries X-Wait-Timeout and X-Timeout-Policy (PER-16339). The test holds the set of facts routes the PDP waits on before it answers. Those are the only /facts operations the PDP's spec lists, and a test keeps the set equal to the committed copy of that spec. The API coverage allowlist drops the five PDP operations these tests now cover, and lists the other proxied routes the PDP forwards without listing them in its spec (PER-16338). Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage_allowlist.json | 154 ++++-- tests/test_facts_sync_offline.py | 539 +++++++++++++++++++- 2 files changed, 633 insertions(+), 60 deletions(-) diff --git a/.github/scripts/api_coverage_allowlist.json b/.github/scripts/api_coverage_allowlist.json index 9b32c0ee..851b1729 100644 --- a/.github/scripts/api_coverage_allowlist.json +++ b/.github/scripts/api_coverage_allowlist.json @@ -1672,46 +1672,6 @@ "ticket": "PER-16337", "reason": "PDP operator route: forces a policy or data reload." }, - { - "api": "pdp", - "operation": "PATCH /facts/resource_instances/{instance_id}", - "stage": "GA", - "status": "untested", - "ticket": "PER-16177", - "reason": "Called by permit.api.resource_instances.update() with proxy_facts_via_pdp; no offline test sends this request yet." - }, - { - "api": "pdp", - "operation": "DELETE /facts/role_assignments", - "stage": "GA", - "status": "untested", - "ticket": "PER-16177", - "reason": "Called by permit.api.role_assignments.unassign() with proxy_facts_via_pdp; no offline test sends this request yet." - }, - { - "api": "pdp", - "operation": "PUT /facts/users/{user_id}", - "stage": "GA", - "status": "untested", - "ticket": "PER-16177", - "reason": "Called by permit.api.users.sync() with proxy_facts_via_pdp; no offline test sends this request yet." - }, - { - "api": "pdp", - "operation": "PATCH /facts/users/{user_id}", - "stage": "GA", - "status": "untested", - "ticket": "PER-16177", - "reason": "Called by permit.api.users.update() with proxy_facts_via_pdp; no offline test sends this request yet." - }, - { - "api": "pdp", - "operation": "DELETE /facts/users/{user_id}/roles", - "stage": "GA", - "status": "untested", - "ticket": "PER-16177", - "reason": "Called by permit.api.users.unassign_role() with proxy_facts_via_pdp; no offline test sends this request yet." - }, { "api": "pdp", "operation": "GET /healthchecks/opa/healthy", @@ -1824,36 +1784,150 @@ "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.users.bulk_create() here. The PDP passes it through to the control plane's POST /v2/facts/{proj_id}/{env_id}/bulk/users without listing it in its spec, and ignores X-Wait-Timeout on it." }, + { + "request": "PUT /facts/bulk/users", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.users.bulk_replace() here. The PDP passes it through to the control plane's PUT /v2/facts/{proj_id}/{env_id}/bulk/users without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "DELETE /facts/bulk/users", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.users.bulk_delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/bulk/users without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "GET /facts/relationship_tuples", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.relationship_tuples.list() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/relationship_tuples without listing it in its spec." + }, + { + "request": "DELETE /facts/relationship_tuples", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.relationship_tuples.delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/relationship_tuples without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "POST /facts/relationship_tuples/bulk", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.relationship_tuples.bulk_create() here. The PDP passes it through to the control plane's POST /v2/facts/{proj_id}/{env_id}/relationship_tuples/bulk without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "DELETE /facts/relationship_tuples/bulk", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.relationship_tuples.bulk_delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/relationship_tuples/bulk without listing it in its spec, and ignores X-Wait-Timeout on it." + }, { "request": "GET /facts/relationship_tuples/detailed", "status": "undocumented", "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.relationship_tuples.list_detailed() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/relationship_tuples/detailed without listing it in its spec." }, + { + "request": "GET /facts/resource_instances", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.list() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/resource_instances without listing it in its spec." + }, { "request": "GET /facts/resource_instances/detailed", "status": "undocumented", "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.list_detailed() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/resource_instances/detailed without listing it in its spec." }, + { + "request": "GET /facts/resource_instances/{instance_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.get(), get_by_key() and get_by_id() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/resource_instances/{instance_id} without listing it in its spec." + }, + { + "request": "DELETE /facts/resource_instances/{instance_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/resource_instances/{instance_id} without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "GET /facts/role_assignments", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.role_assignments.list() and permit.api.users.get_assigned_roles() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/role_assignments without listing it in its spec." + }, + { + "request": "POST /facts/role_assignments/bulk", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.role_assignments.bulk_assign() here. The PDP passes it through to the control plane's POST /v2/facts/{proj_id}/{env_id}/role_assignments/bulk without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "DELETE /facts/role_assignments/bulk", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.role_assignments.bulk_unassign() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/role_assignments/bulk without listing it in its spec, and ignores X-Wait-Timeout on it." + }, { "request": "GET /facts/role_assignments/detailed", "status": "undocumented", "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.role_assignments.list_detailed() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/role_assignments/detailed without listing it in its spec." }, + { + "request": "GET /facts/tenants", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.list() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/tenants without listing it in its spec." + }, { "request": "DELETE /facts/tenants/{tenant_id}", "status": "undocumented", "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.tenants.delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id} without listing it in its spec, and ignores X-Wait-Timeout on it." }, + { + "request": "GET /facts/tenants/{tenant_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.get(), get_by_key() and get_by_id() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id} without listing it in its spec." + }, + { + "request": "PATCH /facts/tenants/{tenant_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.update() here. The PDP passes it through to the control plane's PATCH /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id} without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "GET /facts/tenants/{tenant_id}/users", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.list_tenant_users() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users without listing it in its spec." + }, { "request": "DELETE /facts/tenants/{tenant_id}/users/{user_id}", "status": "undocumented", "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.tenants.delete_tenant_user() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users/{user_id} without listing it in its spec, and ignores X-Wait-Timeout on it." }, + { + "request": "GET /facts/users", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.users.list() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/users without listing it in its spec." + }, + { + "request": "GET /facts/users/{user_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.users.get(), get_by_key() and get_by_id() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/users/{user_id} without listing it in its spec." + }, + { + "request": "DELETE /facts/users/{user_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.users.delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/users/{user_id} without listing it in its spec, and ignores X-Wait-Timeout on it." + }, { "request": "POST /v2/auth/elements_login_as", "status": "undocumented", diff --git a/tests/test_facts_sync_offline.py b/tests/test_facts_sync_offline.py index d9394c26..3b83735d 100644 --- a/tests/test_facts_sync_offline.py +++ b/tests/test_facts_sync_offline.py @@ -1,24 +1,36 @@ -"""Offline tests for the headers that make the PDP wait for a proxied facts write. +"""Offline tests for the proxied facts requests the PDP waits on before it answers. With ``proxy_facts_via_pdp`` on, the SDK sends its facts requests to the PDP, with the -``X-Wait-Timeout`` header when a facts sync timeout is set (PER-16681). Each call goes -through the async and the blocking client, and the test checks the request it puts on the -wire (method, path, query string, headers and JSON body). Every request is served by a local -``pytest_httpserver``, the API and the PDP each on a server of their own, and the API context -is pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. +``X-Wait-Timeout`` header when a facts sync timeout is set (PER-16681). The PDP waits on +some of its facts routes only, and forwards the others without waiting (PER-16339). These +tests pin the request every facts method sends, and so which of them the PDP waits on. + +Each call goes through the async and the blocking client, and the test checks the request +it puts on the wire (method, path, query string, headers and JSON body). Every request is +served by a local ``pytest_httpserver``, the API and the PDP each on a server of their own, +and the API context is pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are +needed. """ import asyncio import inspect +import json +import re from contextlib import nullcontext from operator import attrgetter -from typing import Any +from pathlib import Path +from typing import Any, NamedTuple import pytest from pytest_httpserver import HTTPServer from werkzeug import Request from permit import Permit +from permit.api.relationship_tuples import RelationshipTuplesApi +from permit.api.resource_instances import ResourceInstancesApi +from permit.api.role_assignments import RoleAssignmentsApi +from permit.api.tenants import TenantsApi +from permit.api.users import UsersApi from permit.config import PermitConfig from permit.sync import Permit as SyncPermit from tests.utils import FACTS, Call, call, offline_config, sent @@ -27,19 +39,44 @@ HEADERS = ("Authorization", "Content-Type", "X-Wait-Timeout", "X-Timeout-Policy") -ENVIRONMENT_ID = "6a1b2c3d-0000-4000-8000-000000000003" -PROJECT_ID = "6a1b2c3d-0000-4000-8000-000000000002" -ORGANIZATION_ID = "6a1b2c3d-0000-4000-8000-000000000001" -CREATED_AT = "2026-01-01T00:00:00+00:00" -USER = { - "key": "alice", - "id": "6a1b2c3d-0000-4000-8000-000000000010", - "organization_id": ORGANIZATION_ID, - "project_id": PROJECT_ID, - "environment_id": ENVIRONMENT_ID, - "created_at": CREATED_AT, - "updated_at": CREATED_AT, -} +REPO_ROOT = Path(__file__).resolve().parents[1] +PDP_SPEC = REPO_ROOT / ".github" / "api-specs" / "pdp.json" + +USER_ID = "6a1b2c3d-0000-4000-8000-000000000010" +TENANT_ID = "6a1b2c3d-0000-4000-8000-000000000020" +INSTANCE_ID = "6a1b2c3d-0000-4000-8000-000000000030" + + +def read(**fields: Any) -> dict[str, Any]: + """A read model's JSON: ``fields``, and the ids and timestamps every read model has.""" + return { + "id": "6a1b2c3d-0000-4000-8000-000000000000", + "organization_id": "6a1b2c3d-0000-4000-8000-000000000001", + "project_id": "6a1b2c3d-0000-4000-8000-000000000002", + "environment_id": "6a1b2c3d-0000-4000-8000-000000000003", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + **fields, + } + + +USER = read(key="alice", id=USER_ID) +TENANT = read(key="acme", name="Acme", id=TENANT_ID, last_action_at="2026-01-01T00:00:00+00:00") +ROLE_ASSIGNMENT = read( + user="alice", role="editor", user_id=USER_ID, role_id=USER_ID, tenant_id=TENANT_ID +) +RESOURCE_INSTANCE = read( + key="readme", resource="document", tenant="default", resource_id=USER_ID, tenant_id=TENANT_ID +) +RELATIONSHIP_TUPLE = read( + subject="folder:docs", + relation="parent", + object="document:readme", + tenant="default", + subject_id=INSTANCE_ID, + relation_id=INSTANCE_ID, + tenant_id=TENANT_ID, +) CREATE_USER = call("api.users.create", {"key": "alice"}) CREATE_USER_SENT = {"method": "POST", "path": "/facts/users", "query": [], "body": {"key": "alice"}} @@ -205,3 +242,465 @@ def test_without_proxy_facts_via_pdp_a_zero_timeout_sends_no_header( assert sent(request) == {**CREATE_USER_SENT, "path": f"{FACTS}/users"} assert sent_headers(request) == facts_headers(None, None) assert pdp_server.log == [] + + +# --- which facts methods the PDP waits on (PER-16339) --------------------------------- + +# The facts routes the container PDP waits on before it answers: until the change is in its +# own data, or for as long as X-Wait-Timeout says. It forwards every other /facts request +# to the API without waiting (PER-16338 asks it to wait on more). These are also the only +# /facts operations the PDP's OpenAPI spec lists: it leaves out the routes it forwards +# without waiting. test_the_synced_routes_are_the_facts_operations_of_the_pdp_spec keeps +# this set and the committed copy of that spec in step. +SYNCED_ROUTES = frozenset( + { + "POST /facts/users", + "PUT /facts/users/{user_id}", + "PATCH /facts/users/{user_id}", + "POST /facts/users/{user_id}/roles", + "DELETE /facts/users/{user_id}/roles", + "POST /facts/tenants", + "POST /facts/role_assignments", + "DELETE /facts/role_assignments", + "POST /facts/resource_instances", + "PATCH /facts/resource_instances/{instance_id}", + "POST /facts/relationship_tuples", + } +) + +# The facts APIs, which send their requests to the PDP with proxy_facts_via_pdp on. +FACTS_APIS: dict[str, type] = { + "users": UsersApi, + "tenants": TenantsApi, + "role_assignments": RoleAssignmentsApi, + "resource_instances": ResourceInstancesApi, + "relationship_tuples": RelationshipTuplesApi, +} + +# Public methods with no case of their own: a deprecated alias calls the method named here. +ALIASES = {"tenants.add_user": "tenants.create_user"} + + +class Case(NamedTuple): + """A facts method, called with ``proxy_facts_via_pdp`` on, and the one request it sends. + + ``route`` is the route the request is for, as " ": a PDP route + under ``/facts``, or the API route of a method that goes to the API either way. + ``response`` is the JSON the server answers with, or None for a 204 with no body. + """ + + call: Call + route: str + path: str + query: tuple[tuple[str, str], ...] = () + body: Any = None + response: Any = None + + +def page(**filters: str) -> tuple[tuple[str, str], ...]: + """The query string of a list request for the first page, sorted as ``sent()`` sorts it.""" + return tuple(sorted({"page": "1", "per_page": "100", **filters}.items())) + + +EMPTY_PAGE = {"data": [], "total_count": 0} +NEW_USER = {"key": "alice"} +NEW_TENANT = {"key": "acme", "name": "Acme"} +ASSIGNMENT = {"user": "alice", "role": "editor", "tenant": "default"} +INSTANCE = {"key": "readme", "resource": "document", "tenant": "default"} +TUPLE_IDENT = {"subject": "folder:docs", "relation": "parent", "object": "document:readme"} +TUPLE = {**TUPLE_IDENT, "tenant": "default"} + +CASES = { + case.call.path.removeprefix("api."): case + for case in [ + # users + Case( + call("api.users.list"), "GET /facts/users", "/facts/users", page(), response=EMPTY_PAGE + ), + Case( + call("api.users.get", "alice"), + "GET /facts/users/{user_id}", + "/facts/users/alice", + response=USER, + ), + Case( + call("api.users.get_by_key", "alice"), + "GET /facts/users/{user_id}", + "/facts/users/alice", + response=USER, + ), + Case( + call("api.users.get_by_id", USER_ID), + "GET /facts/users/{user_id}", + f"/facts/users/{USER_ID}", + response=USER, + ), + Case( + call("api.users.create", NEW_USER), + "POST /facts/users", + "/facts/users", + body=NEW_USER, + response=USER, + ), + Case( + call("api.users.update", "alice", {"first_name": "Alice"}), + "PATCH /facts/users/{user_id}", + "/facts/users/alice", + body={"first_name": "Alice"}, + response=USER, + ), + Case( + call("api.users.sync", NEW_USER), + "PUT /facts/users/{user_id}", + "/facts/users/alice", + body=NEW_USER, + response=USER, + ), + Case( + call("api.users.delete", "alice"), "DELETE /facts/users/{user_id}", "/facts/users/alice" + ), + Case( + call("api.users.bulk_create", [NEW_USER]), + "POST /facts/bulk/users", + "/facts/bulk/users", + body={"operations": [NEW_USER]}, + response={}, + ), + Case( + call("api.users.bulk_replace", [NEW_USER]), + "PUT /facts/bulk/users", + "/facts/bulk/users", + body={"operations": [NEW_USER]}, + response={}, + ), + Case( + call("api.users.bulk_delete", ["alice"]), + "DELETE /facts/bulk/users", + "/facts/bulk/users", + body={"idents": ["alice"]}, + response={}, + ), + Case( + call("api.users.assign_role", ASSIGNMENT), + "POST /facts/users/{user_id}/roles", + "/facts/users/alice/roles", + body={"role": "editor", "tenant": "default"}, + response=ROLE_ASSIGNMENT, + ), + Case( + call("api.users.unassign_role", ASSIGNMENT), + "DELETE /facts/users/{user_id}/roles", + "/facts/users/alice/roles", + body={"role": "editor", "tenant": "default"}, + ), + Case( + call("api.users.get_assigned_roles", "alice", tenant="default"), + "GET /facts/role_assignments", + "/facts/role_assignments", + page(user="alice", tenant="default"), + response=[], + ), + # tenants + Case(call("api.tenants.list"), "GET /facts/tenants", "/facts/tenants", page(), response=[]), + Case( + call("api.tenants.list_tenant_users", "acme"), + "GET /facts/tenants/{tenant_id}/users", + "/facts/tenants/acme/users", + page(), + response=EMPTY_PAGE, + ), + # Goes to the API with or without proxy_facts_via_pdp. It carries the PDP's headers + # all the same, as every request of a client with proxy_facts_via_pdp does; the API + # ignores them. + Case( + call("api.tenants.create_user", "acme", NEW_USER), + "POST /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users", + f"{FACTS}/tenants/acme/users", + body=NEW_USER, + response=USER, + ), + Case( + call("api.tenants.get", "acme"), + "GET /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + response=TENANT, + ), + Case( + call("api.tenants.get_by_key", "acme"), + "GET /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + response=TENANT, + ), + Case( + call("api.tenants.get_by_id", TENANT_ID), + "GET /facts/tenants/{tenant_id}", + f"/facts/tenants/{TENANT_ID}", + response=TENANT, + ), + Case( + call("api.tenants.create", NEW_TENANT), + "POST /facts/tenants", + "/facts/tenants", + body=NEW_TENANT, + response=TENANT, + ), + Case( + call("api.tenants.update", "acme", {"name": "Acme Inc"}), + "PATCH /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + body={"name": "Acme Inc"}, + response=TENANT, + ), + Case( + call("api.tenants.delete", "acme"), + "DELETE /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + ), + Case( + call("api.tenants.delete_tenant_user", "acme", "alice"), + "DELETE /facts/tenants/{tenant_id}/users/{user_id}", + "/facts/tenants/acme/users/alice", + ), + Case( + call("api.tenants.bulk_create", [NEW_TENANT]), + "POST /facts/bulk/tenants", + "/facts/bulk/tenants", + body={"operations": [NEW_TENANT]}, + response={}, + ), + Case( + call("api.tenants.bulk_delete", ["acme"]), + "DELETE /facts/bulk/tenants", + "/facts/bulk/tenants", + body={"idents": ["acme"]}, + response={}, + ), + # role assignments + Case( + call("api.role_assignments.list", user_key="alice"), + "GET /facts/role_assignments", + "/facts/role_assignments", + page(user="alice"), + response=[], + ), + Case( + call("api.role_assignments.list_detailed", user_key="alice"), + "GET /facts/role_assignments/detailed", + "/facts/role_assignments/detailed", + page(user="alice"), + response=EMPTY_PAGE, + ), + Case( + call("api.role_assignments.assign", ASSIGNMENT), + "POST /facts/role_assignments", + "/facts/role_assignments", + body=ASSIGNMENT, + response=ROLE_ASSIGNMENT, + ), + Case( + call("api.role_assignments.unassign", ASSIGNMENT), + "DELETE /facts/role_assignments", + "/facts/role_assignments", + body=ASSIGNMENT, + ), + Case( + call("api.role_assignments.bulk_assign", [ASSIGNMENT]), + "POST /facts/role_assignments/bulk", + "/facts/role_assignments/bulk", + body=[ASSIGNMENT], + response={}, + ), + Case( + call("api.role_assignments.bulk_unassign", [ASSIGNMENT]), + "DELETE /facts/role_assignments/bulk", + "/facts/role_assignments/bulk", + body=[ASSIGNMENT], + response={}, + ), + # resource instances + Case( + call("api.resource_instances.list"), + "GET /facts/resource_instances", + "/facts/resource_instances", + page(), + response=[], + ), + Case( + call("api.resource_instances.list_detailed"), + "GET /facts/resource_instances/detailed", + "/facts/resource_instances/detailed", + page(), + response=EMPTY_PAGE, + ), + Case( + call("api.resource_instances.get", "document:readme"), + "GET /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.get_by_key", "document:readme"), + "GET /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.get_by_id", INSTANCE_ID), + "GET /facts/resource_instances/{instance_id}", + f"/facts/resource_instances/{INSTANCE_ID}", + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.create", INSTANCE), + "POST /facts/resource_instances", + "/facts/resource_instances", + body=INSTANCE, + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.update", "document:readme", {"attributes": {"pages": 3}}), + "PATCH /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + body={"attributes": {"pages": 3}}, + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.delete", "document:readme"), + "DELETE /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + ), + Case( + call("api.resource_instances.bulk_replace", [INSTANCE]), + "PUT /facts/bulk/resource_instances", + "/facts/bulk/resource_instances", + body={"operations": [INSTANCE]}, + response={}, + ), + Case( + call("api.resource_instances.bulk_delete", ["document:readme"]), + "DELETE /facts/bulk/resource_instances", + "/facts/bulk/resource_instances", + body={"idents": ["document:readme"]}, + response={}, + ), + # relationship tuples + Case( + call("api.relationship_tuples.list"), + "GET /facts/relationship_tuples", + "/facts/relationship_tuples", + page(), + response=[], + ), + Case( + call("api.relationship_tuples.list_detailed"), + "GET /facts/relationship_tuples/detailed", + "/facts/relationship_tuples/detailed", + page(), + response=EMPTY_PAGE, + ), + Case( + call("api.relationship_tuples.create", TUPLE), + "POST /facts/relationship_tuples", + "/facts/relationship_tuples", + body=TUPLE, + response=RELATIONSHIP_TUPLE, + ), + Case( + call("api.relationship_tuples.delete", TUPLE_IDENT), + "DELETE /facts/relationship_tuples", + "/facts/relationship_tuples", + body=TUPLE_IDENT, + ), + Case( + call("api.relationship_tuples.bulk_create", [TUPLE]), + "POST /facts/relationship_tuples/bulk", + "/facts/relationship_tuples/bulk", + body={"operations": [TUPLE]}, + response={}, + ), + Case( + call("api.relationship_tuples.bulk_delete", [TUPLE_IDENT]), + "DELETE /facts/relationship_tuples/bulk", + "/facts/relationship_tuples/bulk", + body={"idents": [TUPLE_IDENT]}, + response={}, + ), + ] +} + + +def on_pdp(case: Case) -> bool: + """Whether the method sends its request to the PDP rather than to the API.""" + return case.route.split(" ")[1].startswith("/facts/") + + +def route_matches(route: str, method: str, path: str) -> bool: + """Whether a request for ``method`` and ``path`` is one for ``route``.""" + route_method, template = route.split(" ") + pattern = re.sub(r"\{[^/{}]+\}", "[^/]+", template) + return route_method == method and re.fullmatch(pattern, path) is not None + + +def test_every_public_facts_method_has_a_case() -> None: + public = { + f"{api}.{name}" + for api, api_class in FACTS_APIS.items() + for name, value in vars(api_class).items() + if not name.startswith("_") and callable(value) + } + + assert set(CASES) == public - set(ALIASES) + assert set(ALIASES.values()) <= set(CASES) + + +def test_the_synced_routes_are_the_facts_operations_of_the_pdp_spec() -> None: + """A refreshed PDP spec that lists another /facts route means the PDP may wait on it.""" + spec = json.loads(PDP_SPEC.read_text(encoding="utf-8")) + facts_operations = { + f"{method.upper()} {path}" + for path, operations in spec["paths"].items() + if path.startswith("/facts/") + for method in operations + } + + assert facts_operations == SYNCED_ROUTES + + +def test_every_synced_route_is_the_route_of_a_case() -> None: + """So a case for a synced route spells it as SYNCED_ROUTES does, and counts as waiting.""" + assert {case.route for case in CASES.values()} >= SYNCED_ROUTES + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("case", CASES.values(), ids=CASES.keys()) +def test_a_proxied_facts_method_sends_the_sync_headers_to_its_route( + *, httpserver: HTTPServer, pdp_server: HTTPServer, case: Case, flavour: str +) -> None: + """Every facts request carries X-Wait-Timeout; the PDP waits only on SYNCED_ROUTES.""" + config = make_config( + httpserver, + pdp_server, + proxy_facts_via_pdp=True, + facts_sync_timeout=2.5, + facts_sync_timeout_policy="fail", + ) + method = case.route.split(" ")[0] + server, other = (pdp_server, httpserver) if on_pdp(case) else (httpserver, pdp_server) + handler = server.expect_request(case.path, method=method) + if case.response is None: + handler.respond_with_data("", status=204) + else: + handler.respond_with_json(case.response) + + invoke(config, flavour, case.call) + + [(request, _)] = server.log + assert route_matches(case.route, request.method, request.path) + assert sent(request) == { + "method": method, + "path": case.path, + "query": list(case.query), + "body": case.body, + } + assert sent_headers(request) == facts_headers("2.5", "fail") + assert other.log == [] From 31aefaeb88a91f4732cfa5044120241971039bc5 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:22:51 +0300 Subject: [PATCH 06/20] Document which proxied facts writes the PDP waits on The PDP waits until it has a facts write before it answers on 11 routes only, and forwards every other facts request without waiting. The docstring of wait_for_sync(), the descriptions of proxy_facts_via_pdp and facts_sync_timeout and a new README section, "Read-your-writes through the PDP", now list the methods it waits on. The README also lists the writes it does not wait on, and says what a timeout of 0 or None and each timeout policy do (PER-16339). A test reads the four lists and checks that each names exactly the methods whose route the PDP waits on, as the offline wire tests pin them, so the docs cannot drift from the code. Co-Authored-By: Claude Opus 5.5 --- README.md | 51 ++++++++++++++++++++++++ permit/config.py | 24 +++++++++-- permit/permit.py | 36 ++++++++++++----- tests/test_facts_sync_offline.py | 68 ++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f7def99d..e93f8b96 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,57 @@ print(refreshed.update_id, refreshed.pdp_ids) the SDK's API context set to the environment. The API rejects a read-only key with 403, and answers 404 for an environment with no PDP configuration. +## Read-your-writes through the PDP + +With `proxy_facts_via_pdp=True`, the facts methods of `permit.api`, those of its `users`, +`tenants`, `role_assignments`, `resource_instances` and `relationship_tuples` APIs, send their +requests to the PDP, which forwards them to the Permit REST API. Only the container PDP serves +them: the cloud PDP answers 404. On some of these writes, the PDP also waits until the change +is in its own data before it answers, so that a check sent next sees the change: + +```py +permit = Permit(token="", pdp="http://localhost:7766", proxy_facts_via_pdp=True) +with permit.wait_for_sync(timeout=5) as synced: + await synced.api.users.assign_role({"user": "alice", "role": "editor", "tenant": "default"}) +# Allowed, if the editor role grants "edit" on documents: +await permit.check("alice", "edit", {"type": "document", "tenant": "default"}) +``` + +The PDP waits on the writes of these methods only: + +- `users.create()`, `users.update()`, `users.sync()`, `users.assign_role()` and + `users.unassign_role()`; +- `tenants.create()`; +- `role_assignments.assign()` and `role_assignments.unassign()`; +- `resource_instances.create()` and `resource_instances.update()`; +- `relationship_tuples.create()`. + +It forwards every other facts request without waiting, reads included. These writes return +before the PDP has the change, so a check sent right after one may still see the old data: + +- `users.delete()`, `users.bulk_create()`, `users.bulk_replace()` and `users.bulk_delete()`; +- `tenants.update()`, `tenants.delete()`, `tenants.delete_tenant_user()`, + `tenants.bulk_create()` and `tenants.bulk_delete()`; +- `role_assignments.bulk_assign()` and `role_assignments.bulk_unassign()`; +- `resource_instances.delete()`, `resource_instances.bulk_replace()` and + `resource_instances.bulk_delete()`; +- `relationship_tuples.delete()`, `relationship_tuples.bulk_create()` and + `relationship_tuples.bulk_delete()`. + +`tenants.create_user()` always goes to the API, so it does not wait either. A deprecated flat +method on `permit.api`, such as `permit.api.sync_user()`, waits when the method its warning +names does. + +- How long the PDP waits is `facts_sync_timeout`, or the `timeout` of `wait_for_sync()` for + the client it yields, sent as the `X-Wait-Timeout` header. `0` makes the PDP answer without + waiting. With `None`, the default of `facts_sync_timeout`, the SDK sends no header, and the + PDP waits its own default: 10 seconds, unless its `PDP_LOCAL_FACTS_WAIT_TIMEOUT` sets + another. +- `facts_sync_timeout_policy`, or the `policy` of `wait_for_sync()`, says what the PDP does + when the time is up first: `"ignore"` answers with the write's own response, and `"fail"` + answers 424, which the SDK raises as a `PermitApiError`. The write is done either way. +- The blocking client, `permit.sync.Permit`, waits on the same methods. + ## Type checking The package ships a `py.typed` marker (PEP 561), so mypy, pyright and IDEs check your diff --git a/permit/config.py b/permit/config.py index 490c6576..1a7ee3c8 100644 --- a/permit/config.py +++ b/permit/config.py @@ -111,12 +111,30 @@ class PermitConfig(BaseModel): ) proxy_facts_via_pdp: bool = Field( default=False, - description="Create facts via the PDP API instead of using the default Permit REST API.", + description="Send the facts requests of permit.api, those of its users, tenants, " + "role_assignments, resource_instances and relationship_tuples APIs, to the PDP, which " + "forwards them to the Permit REST API. Only the container PDP serves them: the cloud " + "PDP answers 404. The PDP waits until it has the change before it answers on the " + "writes of users.create(), users.update(), users.sync(), users.assign_role(), " + "users.unassign_role(), tenants.create(), role_assignments.assign(), " + "role_assignments.unassign(), resource_instances.create(), resource_instances.update() " + "and relationship_tuples.create() only, for up to facts_sync_timeout seconds, or its " + "own default when that is None. It forwards every other facts request without waiting, " + "such as users.delete(), tenants.update() and the bulk methods. tenants.create_user() " + "always goes to the API.", ) facts_sync_timeout: float | None = Field( default=None, - description="The amount of time in seconds to wait for facts to be available " - "in the PDP cache before returning the response.", + description="With proxy_facts_via_pdp on, how many seconds the PDP waits for a facts " + "write to reach its own data before it answers, sent as the X-Wait-Timeout header. 0 " + "makes it answer without waiting. None sends no header, so the PDP waits its own " + "default: 10 seconds, unless the PDP's PDP_LOCAL_FACTS_WAIT_TIMEOUT sets another. The " + "PDP waits on the writes of users.create(), users.update(), users.sync(), " + "users.assign_role(), users.unassign_role(), tenants.create(), " + "role_assignments.assign(), role_assignments.unassign(), resource_instances.create(), " + "resource_instances.update() and relationship_tuples.create() only. It forwards every " + "other facts request without waiting, such as users.delete(), tenants.update() and " + "the bulk methods.", ) facts_sync_timeout_policy: Literal["ignore", "fail"] | None = Field( default=None, diff --git a/permit/permit.py b/permit/permit.py index 1754b6db..066b2f33 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -128,18 +128,34 @@ def config(self) -> PermitConfig: def wait_for_sync( self, timeout: float = 10.0, policy: Literal["ignore", "fail"] | None = None ) -> Generator[Self, None, None]: - """Context manager returning a client that waits for facts to be synced. - - Requests made through the returned client wait for the facts they write to be - available in the PDP before proceeding. + """Context manager yielding a client whose facts writes wait for the PDP to have them. + + With ``proxy_facts_via_pdp`` on, the yielded client sends ``timeout`` with each facts + request, as the ``X-Wait-Timeout`` header. The container PDP waits on the writes of + these methods of ``permit.api`` only, until the change is in its own data or the + timeout passes, so that a check sent next sees the change: + + - ``users.create()``, ``users.update()``, ``users.sync()``, ``users.assign_role()`` + and ``users.unassign_role()``; + - ``tenants.create()``; + - ``role_assignments.assign()`` and ``role_assignments.unassign()``; + - ``resource_instances.create()`` and ``resource_instances.update()``; + - ``relationship_tuples.create()``. + + The PDP forwards every other facts request without waiting, reads included, so these + writes return before the PDP has the change: ``users.delete()``, ``tenants.update()``, + ``tenants.delete()``, ``tenants.delete_tenant_user()``, ``resource_instances.delete()``, + ``relationship_tuples.delete()`` and the bulk methods. + ``tenants.create_user()`` goes to the Permit REST API, so it does not wait either. Args: - timeout: The amount of time in seconds to wait for facts to be available in the PDP - cache before returning the response. - policy: Weather to fail the request when the timeout is reached or ignore. - - Set None to keep the default policy set in the instance config or the default value of - PDP. + timeout: How many seconds the PDP waits for the change before it answers. 0 makes + it answer without waiting. + policy: What the PDP does when the timeout passes first: "ignore" answers with the + write's own response, and "fail" answers 424, which the SDK raises as a + ``PermitApiError``; the write is done either way. None keeps the + ``facts_sync_timeout_policy`` of this client's config, or the PDP's own + default when that is None too. Yields: Permit: A Permit instance that is configured to wait for facts to be synced. It diff --git a/tests/test_facts_sync_offline.py b/tests/test_facts_sync_offline.py index 3b83735d..60816c68 100644 --- a/tests/test_facts_sync_offline.py +++ b/tests/test_facts_sync_offline.py @@ -16,6 +16,7 @@ import inspect import json import re +from collections.abc import Callable from contextlib import nullcontext from operator import attrgetter from pathlib import Path @@ -704,3 +705,70 @@ def test_a_proxied_facts_method_sends_the_sync_headers_to_its_route( } assert sent_headers(request) == facts_headers("2.5", "fail") assert other.log == [] + + +# --- the documented lists of the methods the PDP waits on ------------------------------ + +# The facts methods whose request goes to a route the PDP waits on, and the writes whose +# request goes to one it forwards without waiting, as the cases above pin them. +WAITING = frozenset( + name for name, case in CASES.items() if on_pdp(case) and case.route in SYNCED_ROUTES +) +FORWARDED_WRITES = frozenset( + name + for name, case in CASES.items() + if on_pdp(case) and case.route not in SYNCED_ROUTES and not case.route.startswith("GET ") +) + +# Each documented list of the methods the PDP waits on ends with this phrase. A facts +# method named after it is one the PDP does not wait on. +FORWARDED = "forwards every other facts request without waiting" +READ_YOUR_WRITES = "Read-your-writes through the PDP" + + +def readme_section(title: str) -> str: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + _, found, rest = readme.partition(f"\n## {title}\n") + assert found, f"README.md has no section {title!r}" + return rest.split("\n## ", 1)[0] + + +def config_description(field: str) -> str | None: + description: str | None = PermitConfig.__fields__[field].field_info.description + return description + + +# Where the methods the PDP waits on are listed, and how to read the text that lists them. +DOCUMENTED: dict[str, Callable[[], str | None]] = { + "readme": lambda: readme_section(READ_YOUR_WRITES), + "wait_for_sync": lambda: inspect.getdoc(Permit.wait_for_sync), + "proxy_facts_via_pdp": lambda: config_description("proxy_facts_via_pdp"), + "facts_sync_timeout": lambda: config_description("facts_sync_timeout"), +} + + +def facts_methods(text: str) -> set[str]: + """The facts methods ``text`` names as ".()", as ".".""" + return { + f"{api}.{method}" + for api, method in re.findall(r"(\w+)\.(\w+)\(\)", text) + if api in FACTS_APIS + } + + +@pytest.mark.parametrize("where", DOCUMENTED) +def test_the_docs_list_the_methods_the_pdp_waits_on(where: str) -> None: + """Each list names the methods whose route the PDP waits on, and those alone.""" + text = DOCUMENTED[where]() + assert text is not None + assert text.count(FORWARDED) == 1, f"{where} does not say {FORWARDED!r} once" + waiting, _, forwarded = text.partition(FORWARDED) + + assert facts_methods(waiting) == WAITING + assert not facts_methods(forwarded) & WAITING + + +def test_the_readme_lists_the_writes_the_pdp_does_not_wait_on() -> None: + _, _, forwarded = readme_section(READ_YOUR_WRITES).partition(FORWARDED) + + assert facts_methods(forwarded) >= FORWARDED_WRITES From 9a158306e5f0848ce86bd3c20b2a07b3b4ddd195 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:29:10 +0300 Subject: [PATCH 07/20] Warn when proxy_facts_via_pdp points at the cloud PDP A client created with proxy_facts_via_pdp on and the cloud PDP's host as its pdp sends every facts request to /facts routes the cloud PDP does not serve. Its creation now issues a UserWarning that says so, attributed to the line that created the client, on the async and the blocking client alike, and past a subclass's super().__init__(). It is issued at each creation; Python's default filter shows it once per line. A client that wait_for_sync() yields does not issue it. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- permit/permit.py | 14 +++ permit/sync.py | 4 + permit/utils/cloud_pdp.py | 17 +++ permit/utils/sync.py | 26 +++- tests/test_container_pdp_only_offline.py | 150 ++++++++++++++++++++++- 5 files changed, 208 insertions(+), 3 deletions(-) diff --git a/permit/permit.py b/permit/permit.py index ad754c0d..b0f3d09f 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -19,9 +19,11 @@ from permit.enforcement.interfaces import AuthorizedUsersResult, TenantDetails from permit.logger import configure_logger from permit.pdp_api.pdp_api_client import PermitPdpApiClient +from permit.utils.cloud_pdp import facts_proxied_to_the_cloud_pdp, is_cloud_pdp from permit.utils.context import Context from permit.utils.http_sessions import LoopSessions from permit.utils.sdk_logger import sdk_logger +from permit.utils.sync import creation_site class Permit: @@ -43,10 +45,22 @@ class Permit: config: The SDK configuration. **options: `PermitConfig` fields, used to build the configuration when `config` is not given. + + Warns: + UserWarning: When ``proxy_facts_via_pdp`` is on and ``pdp`` is the cloud PDP's + address. The facts methods of ``permit.api`` then send their requests to the + PDP's ``/facts`` routes, which the cloud PDP does not serve. It is issued each + time such a client is created, at the line that creates it, so Python's default + warning filter shows it once for each such line. A client that + ``wait_for_sync()`` yields does not issue it again. """ def __init__(self, config: PermitConfig | None = None, **options: Any) -> None: self._config: PermitConfig = config if config is not None else PermitConfig(**options) + if self._config.proxy_facts_via_pdp and is_cloud_pdp(self._config.pdp): + # At the line that created the client, past the blocking client's __init__, + # which calls this one: no warnings.warn() stacklevel fits both clients. + creation_site(self).warn(facts_proxied_to_the_cloud_pdp(self._config.pdp), UserWarning) configure_logger(self._config) self._api_sessions = LoopSessions() diff --git a/permit/sync.py b/permit/sync.py index 8c693340..2f9320b1 100644 --- a/permit/sync.py +++ b/permit/sync.py @@ -43,6 +43,10 @@ class Permit(AsyncPermit): **options: `PermitConfig` fields, used to build the configuration when `config` is not given. + Warns: + UserWarning: When ``proxy_facts_via_pdp`` is on and ``pdp`` is the cloud PDP's + address, as for the async client, ``permit.Permit``. + Examples: with Permit(token="") as permit: permit.check("user", "read", "document") diff --git a/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py index 0fde5ba0..96a73210 100644 --- a/permit/utils/cloud_pdp.py +++ b/permit/utils/cloud_pdp.py @@ -87,3 +87,20 @@ def container_pdp_only_message( f"{advice}\n" f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) + + +def facts_proxied_to_the_cloud_pdp(pdp_url: str) -> str: + """The warning for a client with ``proxy_facts_via_pdp`` on whose PDP is the cloud PDP. + + Args: + pdp_url: The cloud PDP's address, as the SDK's ``pdp`` setting gives it. + + Returns: + The warning's text. + """ + return ( + f"proxy_facts_via_pdp is on, so the facts methods of permit.api send their requests to " + f"the PDP's /facts routes, but pdp is the cloud PDP ({pdp_url}), which does not serve " + f"them: each of those requests will fail with status code 404. Point pdp at a " + f"container PDP, or turn proxy_facts_via_pdp off to send facts to the Permit REST API." + ) diff --git a/permit/utils/sync.py b/permit/utils/sync.py index 3d80aa58..e27320ac 100644 --- a/permit/utils/sync.py +++ b/permit/utils/sync.py @@ -13,7 +13,7 @@ from concurrent.futures import ThreadPoolExecutor from contextvars import ContextVar from functools import wraps -from types import FrameType +from types import FrameType, FunctionType from typing import ( Any, NamedTuple, @@ -83,6 +83,30 @@ def warn(self, message: str, category: type[Warning]) -> None: ) +def creation_site(instance: object) -> _CallSite: + """The line that created ``instance``, for a warning that its ``__init__`` issues. + + Call it from an ``__init__`` of one of ``instance``'s classes. It returns the line of the + first frame outside the ``__init__`` methods of those classes: the line that called the + class, past the ``super().__init__()`` calls between, such as the blocking client's. + + Args: + instance: The object being created. + + Returns: + The line that created it, which a warning can be attributed to with ``warn()``. + """ + inits = { + init.__code__ + for cls in type(instance).__mro__ + if isinstance(init := vars(cls).get("__init__"), FunctionType) + } + frame: FrameType | None = sys._getframe(1) # noqa: SLF001 - see run_coroutine_sync + while frame is not None and frame.f_code in inits: + frame = frame.f_back + return _CallSite.from_frame(frame) + + _blocking_call_site: ContextVar[_CallSite | None] = ContextVar( "permit_blocking_call_site", default=None ) diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index 396b81fb..4ac1654f 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -7,7 +7,8 @@ ``proxy_facts_via_pdp`` on. The SDK raises that 404 as an error that names the route and says it needs the container PDP, and keeps its usual error for a 404 that is a real "not found": a container PDP's, which has a JSON body, or the API's, which a container PDP's -``/facts`` routes pass on. +``/facts`` routes pass on. A client created with ``proxy_facts_via_pdp`` on and the cloud +PDP as its ``pdp`` warns, at the line that created it. Each call goes through the async and the blocking client, each closed once the call returns. Every request is served by a local ``pytest_httpserver`` and the API context is @@ -17,9 +18,11 @@ import asyncio import inspect import socket +import sys +import warnings from collections.abc import Iterator from operator import attrgetter -from typing import Any, NamedTuple +from typing import Any, Literal, NamedTuple import pytest from pytest_httpserver import HTTPServer @@ -556,3 +559,146 @@ def test_a_facts_api_method_that_always_goes_to_the_api_does_not_say_container_p client: Permit, path: str ) -> None: assert "Container PDP only" not in docstring(client, path) + + +# --- the warning at creation ---------------------------------------------------------- + + +CLOUD_PDP_URL = f"https://{CLOUD_PDP_HOST}" + + +def facts_proxied_to_the_cloud_pdp(pdp_url: str) -> str: + return ( + "proxy_facts_via_pdp is on, so the facts methods of permit.api send their requests to " + f"the PDP's /facts routes, but pdp is the cloud PDP ({pdp_url}), which does not serve " + "them: each of those requests will fail with status code 404. Point pdp at a container " + "PDP, or turn proxy_facts_via_pdp off to send facts to the Permit REST API." + ) + + +def create(config: PermitConfig, flavour: str) -> Permit: + return Permit(config) if flavour == "async" else SyncPermit(config) + + +def close(client: Permit) -> None: + if isinstance(client, SyncPermit): + client.close() + else: + asyncio.run(client.close()) + + +def caught_as_issued(caught: list[warnings.WarningMessage]) -> list[tuple[Any, ...]]: + return [(w.category, str(w.message), w.filename, w.lineno) for w in caught] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_a_client_that_proxies_facts_to_the_cloud_pdp_warns_at_the_line_that_created_it( + config: PermitConfig, flavour: str +) -> None: + config.pdp = CLOUD_PDP_URL + config.proxy_facts_via_pdp = True + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + line = sys._getframe().f_lineno + 1 + client = Permit(config) if flavour == "async" else SyncPermit(config) + close(client) + + assert caught_as_issued(caught) == [ + (UserWarning, facts_proxied_to_the_cloud_pdp(CLOUD_PDP_URL), __file__, line) + ] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + ("pdp", "proxy_facts_via_pdp"), + [("http://localhost:7766", True), (CLOUD_PDP_URL, False), ("http://localhost:7766", False)], + ids=["container-pdp", "proxy-off", "container-pdp-proxy-off"], +) +def test_no_warning_without_both_the_facts_proxy_and_the_cloud_pdp( + *, config: PermitConfig, pdp: str, proxy_facts_via_pdp: bool, flavour: str +) -> None: + config.pdp = pdp + config.proxy_facts_via_pdp = proxy_facts_via_pdp + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = create(config, flavour) + close(client) + + assert caught == [] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize(("action", "shown"), [("always", 2), ("default", 1)]) +def test_each_creation_warns_and_the_default_filter_shows_it_once_per_line( + config: PermitConfig, action: Literal["always", "default"], shown: int, flavour: str +) -> None: + config.pdp = CLOUD_PDP_URL + config.proxy_facts_via_pdp = True + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter(action) + for _ in range(2): + close(create(config, flavour)) + + assert len(caught) == shown + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_a_wait_for_sync_client_does_not_warn_again(config: PermitConfig, flavour: str) -> None: + config.pdp = CLOUD_PDP_URL + config.proxy_facts_via_pdp = True + with pytest.warns(UserWarning, match="^proxy_facts_via_pdp is on"): + client = create(config, flavour) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with client.wait_for_sync(timeout=1.0) as waiting: + assert waiting is not client + close(client) + + assert caught == [] + + +def test_a_subclass_warns_at_the_line_that_created_it(config: PermitConfig) -> None: + class Subclass(Permit): + def __init__(self, config: PermitConfig) -> None: + super().__init__(config) + + config.pdp = CLOUD_PDP_URL + config.proxy_facts_via_pdp = True + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + line = sys._getframe().f_lineno + 1 + client = Subclass(config) + close(client) + + assert [(w.filename, w.lineno) for w in caught] == [(__file__, line)] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_facts_through_the_cloud_pdps_host_warn_then_ask_for_a_container_pdp( + pdp_server: HTTPServer, split_config: PermitConfig, cloud_pdp_url: str, flavour: str +) -> None: + """At the cloud PDP's address, a 404 with a body counts as the cloud PDP's too.""" + split_config.pdp = cloud_pdp_url + split_config.proxy_facts_via_pdp = True + pdp_server.expect_request("/facts/users", method="POST").respond_with_data( + '{"detail": "Not Found"}', status=404, content_type="application/json" + ) + + with ( + pytest.warns(UserWarning, match="^proxy_facts_via_pdp is on") as caught, + pytest.raises(PermitApiError) as raised, + ): + invoke(split_config, flavour, call("api.users.create", USER)) + + assert [str(w.message) for w in caught] == [facts_proxied_to_the_cloud_pdp(cloud_pdp_url)] + message = container_pdp_only("POST /facts/users", cloud_pdp_url, USE_A_CONTAINER_PDP_FOR_FACTS) + assert type(raised.value) is PermitApiError + assert str(raised.value) == message + assert [sent(request) for request, _ in pdp_server.log] == [ + {"method": "POST", "path": "/facts/users", "query": [], "body": USER} + ] From 0073941b96b006ca56f49c218c9bdd8ffbfe577c Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:30:59 +0300 Subject: [PATCH 08/20] Skip container-PDP-only e2e tests on the cloud PDP The container_pdp fixture, which skipped the get_user_tenants e2e tests when the PDP in use is the cloud PDP, moves to tests/conftest.py with a reason that covers every container-only route. The e2e tests that call permit.pdp_api or write facts through proxy_facts_via_pdp use it too, so a run with CLOUD_PDP=true, or PDP_URL set to the cloud PDP, reports them as skipped instead of failing on the cloud PDP's 404. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 8 +++++--- tests/conftest.py | 18 ++++++++++++++++++ tests/test_rbac_e2e.py | 2 ++ tests/test_rbac_e2e_sync.py | 1 + tests/test_tenant_membership_e2e.py | 20 +++----------------- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ca21a1e..d69b4892 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,9 +172,11 @@ The jobs set: - `PDP_API_KEY`: the scratch environment's API key. Every e2e test fails without it. - `PDP_URL`: `http://localhost:7766`, the PDP container, or `https://cloudpdp.api.permit.io` in `e2e (cloud PDP)`. When it is unset, `tests/test_cloud_pdp_e2e.py` uses the cloud PDP - and every other test `http://localhost:7766`. The `get_user_tenants` tests in - `tests/test_tenant_membership_e2e.py` need a PDP container, because the cloud PDP does not - serve that query, so they skip, with the reason, when `PDP_URL` is the cloud PDP. + and every other test `http://localhost:7766`, or the cloud PDP with `CLOUD_PDP=true`. The + tests of what only a container PDP serves (`get_user_tenants`, `permit.pdp_api`, and facts + written with `proxy_facts_via_pdp`) need a PDP container, because the cloud PDP answers 404 + for those routes. They use the `container_pdp` fixture of `tests/conftest.py`, so they skip, + with the reason, when the PDP they would call is the cloud PDP. - `API_TIER=prod`: sends the SDK's API calls to `https://api.permit.io`. - `ORG_PDP_API_KEY` and `PROJECT_PDP_API_KEY`: the same key, read by `tests/endpoints/test_envs.py`. diff --git a/tests/conftest.py b/tests/conftest.py index 50a64905..3743640b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,6 +75,24 @@ def permit(permit_config: PermitConfig) -> Permit: return Permit(permit_config) +@pytest.fixture +def container_pdp(permit_config: PermitConfig) -> None: + """Skip the test when the PDP the ``permit`` fixtures call is the hosted cloud PDP. + + For the tests of what only a container PDP serves: ``get_user_tenants``, the routes of + ``permit.pdp_api``, and the ``/facts`` routes that ``proxy_facts_via_pdp`` sends facts + to. The cloud PDP answers 404 for each. That PDP is the one PDP_URL names, or with + PDP_URL unset and CLOUD_PDP=true, the cloud PDP. The CI jobs that run these tests start + a container PDP and point PDP_URL at it. + """ + if permit_config.pdp.startswith(CLOUD_PDP_URL): + pytest.skip( + f"container-PDP-only test: the PDP in use is the cloud PDP ({permit_config.pdp}), " + "which does not serve get_user_tenants, permit.pdp_api or the /facts routes of " + "proxy_facts_via_pdp. Point PDP_URL at a container PDP." + ) + + @pytest.fixture def sync_permit(permit_config: PermitConfig) -> SyncPermit: return SyncPermit(permit_config) diff --git a/tests/test_rbac_e2e.py b/tests/test_rbac_e2e.py index 3ceea41e..337da89f 100644 --- a/tests/test_rbac_e2e.py +++ b/tests/test_rbac_e2e.py @@ -305,6 +305,7 @@ async def setup_env( @pytest.mark.e2e +@pytest.mark.usefixtures("container_pdp") # it lists role assignments with permit.pdp_api async def test_permission_check_e2e( permit: Permit, setup_env: tuple[ResourceRead, RoleRead, RoleRead], @@ -531,6 +532,7 @@ async def assignment_listed() -> bool: @pytest.mark.e2e +@pytest.mark.usefixtures("container_pdp") # it writes facts with proxy_facts_via_pdp async def test_local_facts_uploader_permission_check_e2e( permit: Permit, setup_env: tuple[ResourceRead, RoleRead, RoleRead], diff --git a/tests/test_rbac_e2e_sync.py b/tests/test_rbac_e2e_sync.py index 4054f819..729b968c 100644 --- a/tests/test_rbac_e2e_sync.py +++ b/tests/test_rbac_e2e_sync.py @@ -94,6 +94,7 @@ def assert_gone(get: Callable[[str], Any], key: str, description: str) -> None: assert exc_info.value.status_code == 404, f"{description} '{key}' still exists after cleanup" +@pytest.mark.usefixtures("container_pdp") # it lists role assignments with permit.pdp_api def test_permission_check_e2e(sync_permit: SyncPermit) -> None: permit = sync_permit logger.info("initial setup of objects") diff --git a/tests/test_tenant_membership_e2e.py b/tests/test_tenant_membership_e2e.py index e004dbc7..2e921fbe 100644 --- a/tests/test_tenant_membership_e2e.py +++ b/tests/test_tenant_membership_e2e.py @@ -10,7 +10,8 @@ ``get_user_tenants`` asks the PDP for the user's tenants. The PDP lists the tenants in which the user holds a role assigned in the tenant, with each tenant's attributes; a member with no role in a tenant is not listed. Only a container PDP serves the route, so -the tests that call it skip when the PDP in use is the hosted cloud PDP. +the tests that call it skip when the PDP in use is the hosted cloud PDP (conftest.py's +``container_pdp`` fixture). Each test makes its own tenants, role and user in the environment the API key belongs to. Every key is unique to the run, and every delete is registered before the create it @@ -26,11 +27,10 @@ import pytest -from permit import Permit, PermitConfig, User, UserCreate, UserRead +from permit import Permit, User, UserCreate, UserRead from permit.exceptions import PermitApiError from permit.sync import Permit as SyncPermit from tests.utils import ( - CLOUD_PDP_URL, delete_quietly, delete_quietly_blocking, poll_for, @@ -53,20 +53,6 @@ T = TypeVar("T") -@pytest.fixture -def container_pdp(permit_config: PermitConfig) -> None: - """Skip the test when the PDP the ``permit`` fixtures call is the hosted cloud PDP. - - The cloud PDP answers 404 for ``get_user_tenants``. The CI jobs that run this module - start a container PDP and point PDP_URL at it. - """ - if permit_config.pdp.startswith(CLOUD_PDP_URL): - pytest.skip( - f"container-PDP-only test: the PDP in use is the cloud PDP ({permit_config.pdp}), " - "which does not serve get_user_tenants. Point PDP_URL at a container PDP." - ) - - @pytest.fixture async def teardown() -> AsyncIterator[AsyncExitStack]: """The deletes a test registers, run once the test ends.""" From ec8d7fe725115e1857997223c2db70e729734a45 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:31:53 +0300 Subject: [PATCH 09/20] Check the cloud PDP's 404s for pdp_api and proxied facts end to end Two tests join the get_user_tenants one in the e2e (cloud PDP) job: permit.pdp_api.role_assignments.list() and, with proxy_facts_via_pdp on, users.get() raise a PermitApiError that names the route and says only the container PDP serves it. The second also checks the warning at the client's creation. Both only read, so they write nothing even if the cloud PDP ever serves those routes. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 9 +++++---- CONTRIBUTING.md | 9 +++++---- tests/test_cloud_pdp_e2e.py | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37746828..b6b31c15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -262,10 +262,11 @@ jobs: # - cloud PDP: tests/test_cloud_pdp_e2e.py against the hosted cloud PDP. Its # tests build a small RBAC policy in the scratch environment, wait for the # cloud PDP to apply it, and assert the exact answers of check, bulk_check, - # get_user_permissions and filter_objects. One more checks that - # get_user_tenants, which the cloud PDP does not serve, raises the SDK's - # error for its 404. The module skips wherever PDP_URL points at a PDP - # container (see that module). + # get_user_permissions and filter_objects. Three more check that + # get_user_tenants, permit.pdp_api and the facts methods with + # proxy_facts_via_pdp on, whose routes the cloud PDP does not serve, raise + # the SDK's error for its 404. The module skips wherever PDP_URL points at + # a PDP container (see that module). # Each leg makes its own scratch environment, keyed by run, attempt and leg, # so it never shares one with a `pytest` lane, the other leg or a re-run. e2e-unpinned-pdp: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d69b4892..6b5c8f03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,10 +162,11 @@ passed or not: `tests/test_cloud_pdp_e2e.py` against the hosted cloud PDP, `https://cloudpdp.api.permit.io`, with no container. Its tests create a small RBAC policy in the scratch environment, wait for the cloud PDP to apply it, and check the exact - answers of `check`, `bulk_check`, `get_user_permissions` and `filter_objects`. One more - checks that `get_user_tenants`, which the cloud PDP does not serve, raises the SDK's - error for its 404. The module runs only against the cloud PDP and skips anywhere else, - so this job fails if any of its tests is skipped. + answers of `check`, `bulk_check`, `get_user_permissions` and `filter_objects`. Three more + check that `get_user_tenants`, `permit.pdp_api` and the facts methods with + `proxy_facts_via_pdp` on, whose routes the cloud PDP does not serve, raise the SDK's error + for its 404. The module runs only against the cloud PDP and skips anywhere else, so this + job fails if any of its tests is skipped. The jobs set: diff --git a/tests/test_cloud_pdp_e2e.py b/tests/test_cloud_pdp_e2e.py index a66c572d..1a025ecc 100644 --- a/tests/test_cloud_pdp_e2e.py +++ b/tests/test_cloud_pdp_e2e.py @@ -9,8 +9,10 @@ RBAC decides on the resource type and tenant alone, so the resources these tests ask about need not exist as resource instances. -``get_user_tenants`` needs no policy: only the container PDP serves it, and its test -checks that the cloud PDP's 404 for it reaches the caller as the error that says so. +``get_user_tenants``, ``permit.pdp_api`` and the facts methods with ``proxy_facts_via_pdp`` +on need no policy: only the container PDP serves their routes, and their tests check that +the cloud PDP's 404 for each reaches the caller as the error that says so. They only read, +so they write nothing even if the cloud PDP ever serves those routes. """ import functools @@ -22,7 +24,7 @@ import pytest -from permit import Permit, PermitConnectionError +from permit import Permit, PermitApiError, PermitConfig, PermitConnectionError from tests.utils import CLOUD_PDP_URL, delete_quietly, poll_for, unique_key # conftest's `permit_cloud` fixture resolves its address as @@ -283,3 +285,30 @@ async def test_get_user_tenants_is_not_served(permit_cloud: Permit) -> None: assert "got status code 404 from the PDP" in message assert "only the container PDP serves /user-tenants" in message assert raised.value.original_error is None + + +async def test_pdp_api_is_not_served(permit_cloud: Permit) -> None: + with pytest.raises(PermitApiError) as raised: + await permit_cloud.pdp_api.role_assignments.list(user_key=unique_key("cloud-user")) + + assert type(raised.value) is PermitApiError + assert raised.value.status_code == 404 + message = str(raised.value) + assert "got status code 404 from the PDP" in message + assert "only the container PDP serves GET /local/role_assignments" in message + + +async def test_facts_through_the_pdp_are_not_served(permit_config_cloud: PermitConfig) -> None: + permit_config_cloud.proxy_facts_via_pdp = True + with pytest.warns(UserWarning, match="^proxy_facts_via_pdp is on"): + client = Permit(permit_config_cloud) + + async with client: + with pytest.raises(PermitApiError) as raised: + await client.api.users.get(unique_key("cloud-user")) + + assert type(raised.value) is PermitApiError + assert raised.value.status_code == 404 + message = str(raised.value) + assert "got status code 404 from the PDP" in message + assert "only the container PDP serves GET /facts/users/" in message From 0da348a136010f5fe3b62de185a2b912207c0529 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:32:53 +0300 Subject: [PATCH 10/20] Fail fast if the cloud PDP test fixture cannot resolve locally The fixture maps the cloud PDP's host to the local test server through socket.getaddrinfo, which aiohttp uses unless aiodns is installed. It now asserts that, instead of letting a test reach the real host. Refs PER-16340. Co-Authored-By: Claude Opus 5.5 --- tests/test_container_pdp_only_offline.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index 4ac1654f..e9544f65 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -24,6 +24,7 @@ from operator import attrgetter from typing import Any, Literal, NamedTuple +import aiohttp import pytest from pytest_httpserver import HTTPServer from werkzeug import Request @@ -98,6 +99,10 @@ def split_config(config: PermitConfig, pdp_server: HTTPServer) -> PermitConfig: @pytest.fixture def cloud_pdp_url(pdp_server: HTTPServer, monkeypatch: pytest.MonkeyPatch) -> str: """A cloud PDP address whose host resolves to ``pdp_server``, on its port.""" + # aiohttp resolves names with socket.getaddrinfo unless aiodns is installed. + assert aiohttp.resolver.DefaultResolver is aiohttp.ThreadedResolver, ( + "aiohttp resolves names without socket.getaddrinfo here; uninstall aiodns" + ) resolve = socket.getaddrinfo def resolve_the_cloud_pdp_locally( From e7bff658a24a941fc716ba2c3cdff5a16cd80857 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:46:17 +0300 Subject: [PATCH 11/20] Check the cloud PDP's 404 for every proxied facts method The table of every public facts method and the one request it sends with proxy_facts_via_pdp on moves from test_facts_sync_offline.py to tests/facts_methods.py, and the container-PDP-only tests use it too: - the cloud PDP's 404 is now checked for the 47 methods whose request goes to the PDP, not 14, so users.get_assigned_roles' own client and each route of every facts client are covered; - the docstring pins take the methods that say "container PDP only", and those that must not, from the same table, in place of a list typed by hand and a second check of the facts APIs' methods; - Case gives a request's HTTP method and its sent() form, which both modules used to build inline. Co-Authored-By: Claude Opus 5.5 --- tests/facts_methods.py | 435 +++++++++++++++++++++++ tests/test_container_pdp_only_offline.py | 231 ++---------- tests/test_facts_sync_offline.py | 419 +--------------------- 3 files changed, 464 insertions(+), 621 deletions(-) create mode 100644 tests/facts_methods.py diff --git a/tests/facts_methods.py b/tests/facts_methods.py new file mode 100644 index 00000000..d55ef1ce --- /dev/null +++ b/tests/facts_methods.py @@ -0,0 +1,435 @@ +"""Every public facts method of ``permit.api``, and the request it sends to the facts proxy. + +With ``proxy_facts_via_pdp`` on, the facts methods of the ``users``, ``tenants``, +``role_assignments``, ``resource_instances`` and ``relationship_tuples`` APIs send their +requests to the PDP's ``/facts`` routes, which only the container PDP serves, and the PDP +forwards them to the API. ``CASES`` pins the request each method sends, so the tests of what +the PDP waits on (``test_facts_sync_offline.py``) and of the cloud PDP's 404 for those routes +(``test_container_pdp_only_offline.py``) cover the same methods. +""" + +from typing import Any, NamedTuple + +from permit.api.relationship_tuples import RelationshipTuplesApi +from permit.api.resource_instances import ResourceInstancesApi +from permit.api.role_assignments import RoleAssignmentsApi +from permit.api.tenants import TenantsApi +from permit.api.users import UsersApi +from tests.utils import FACTS, Call, call + +USER_ID = "6a1b2c3d-0000-4000-8000-000000000010" +TENANT_ID = "6a1b2c3d-0000-4000-8000-000000000020" +INSTANCE_ID = "6a1b2c3d-0000-4000-8000-000000000030" + + +def read(**fields: Any) -> dict[str, Any]: + """A read model's JSON: ``fields``, and the ids and timestamps every read model has.""" + return { + "id": "6a1b2c3d-0000-4000-8000-000000000000", + "organization_id": "6a1b2c3d-0000-4000-8000-000000000001", + "project_id": "6a1b2c3d-0000-4000-8000-000000000002", + "environment_id": "6a1b2c3d-0000-4000-8000-000000000003", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + **fields, + } + + +USER = read(key="alice", id=USER_ID) +TENANT = read(key="acme", name="Acme", id=TENANT_ID, last_action_at="2026-01-01T00:00:00+00:00") +ROLE_ASSIGNMENT = read( + user="alice", role="editor", user_id=USER_ID, role_id=USER_ID, tenant_id=TENANT_ID +) +RESOURCE_INSTANCE = read( + key="readme", resource="document", tenant="default", resource_id=USER_ID, tenant_id=TENANT_ID +) +RELATIONSHIP_TUPLE = read( + subject="folder:docs", + relation="parent", + object="document:readme", + tenant="default", + subject_id=INSTANCE_ID, + relation_id=INSTANCE_ID, + tenant_id=TENANT_ID, +) + + +# The facts APIs, which send their requests to the PDP with proxy_facts_via_pdp on. +FACTS_APIS: dict[str, type] = { + "users": UsersApi, + "tenants": TenantsApi, + "role_assignments": RoleAssignmentsApi, + "resource_instances": ResourceInstancesApi, + "relationship_tuples": RelationshipTuplesApi, +} + +# Public methods with no case of their own: a deprecated alias calls the method named here. +ALIASES = {"tenants.add_user": "tenants.create_user"} + + +class Case(NamedTuple): + """A facts method, called with ``proxy_facts_via_pdp`` on, and the one request it sends. + + ``route`` is the route the request is for, as " ": a PDP route + under ``/facts``, or the API route of a method that goes to the API either way. + ``response`` is the JSON the server answers with, or None for a 204 with no body. + """ + + call: Call + route: str + path: str + query: tuple[tuple[str, str], ...] = () + body: Any = None + response: Any = None + + @property + def method(self) -> str: + """The request's HTTP method.""" + return self.route.split(" ")[0] + + @property + def request(self) -> dict[str, Any]: + """The request, as ``tests.utils.sent()`` shows it.""" + return { + "method": self.method, + "path": self.path, + "query": list(self.query), + "body": self.body, + } + + +def page(**filters: str) -> tuple[tuple[str, str], ...]: + """The query string of a list request for the first page, sorted as ``sent()`` sorts it.""" + return tuple(sorted({"page": "1", "per_page": "100", **filters}.items())) + + +EMPTY_PAGE = {"data": [], "total_count": 0} +NEW_USER = {"key": "alice"} +NEW_TENANT = {"key": "acme", "name": "Acme"} +ASSIGNMENT = {"user": "alice", "role": "editor", "tenant": "default"} +INSTANCE = {"key": "readme", "resource": "document", "tenant": "default"} +TUPLE_IDENT = {"subject": "folder:docs", "relation": "parent", "object": "document:readme"} +TUPLE = {**TUPLE_IDENT, "tenant": "default"} + +CASES = { + case.call.path.removeprefix("api."): case + for case in [ + # users + Case( + call("api.users.list"), "GET /facts/users", "/facts/users", page(), response=EMPTY_PAGE + ), + Case( + call("api.users.get", "alice"), + "GET /facts/users/{user_id}", + "/facts/users/alice", + response=USER, + ), + Case( + call("api.users.get_by_key", "alice"), + "GET /facts/users/{user_id}", + "/facts/users/alice", + response=USER, + ), + Case( + call("api.users.get_by_id", USER_ID), + "GET /facts/users/{user_id}", + f"/facts/users/{USER_ID}", + response=USER, + ), + Case( + call("api.users.create", NEW_USER), + "POST /facts/users", + "/facts/users", + body=NEW_USER, + response=USER, + ), + Case( + call("api.users.update", "alice", {"first_name": "Alice"}), + "PATCH /facts/users/{user_id}", + "/facts/users/alice", + body={"first_name": "Alice"}, + response=USER, + ), + Case( + call("api.users.sync", NEW_USER), + "PUT /facts/users/{user_id}", + "/facts/users/alice", + body=NEW_USER, + response=USER, + ), + Case( + call("api.users.delete", "alice"), "DELETE /facts/users/{user_id}", "/facts/users/alice" + ), + Case( + call("api.users.bulk_create", [NEW_USER]), + "POST /facts/bulk/users", + "/facts/bulk/users", + body={"operations": [NEW_USER]}, + response={}, + ), + Case( + call("api.users.bulk_replace", [NEW_USER]), + "PUT /facts/bulk/users", + "/facts/bulk/users", + body={"operations": [NEW_USER]}, + response={}, + ), + Case( + call("api.users.bulk_delete", ["alice"]), + "DELETE /facts/bulk/users", + "/facts/bulk/users", + body={"idents": ["alice"]}, + response={}, + ), + Case( + call("api.users.assign_role", ASSIGNMENT), + "POST /facts/users/{user_id}/roles", + "/facts/users/alice/roles", + body={"role": "editor", "tenant": "default"}, + response=ROLE_ASSIGNMENT, + ), + Case( + call("api.users.unassign_role", ASSIGNMENT), + "DELETE /facts/users/{user_id}/roles", + "/facts/users/alice/roles", + body={"role": "editor", "tenant": "default"}, + ), + Case( + call("api.users.get_assigned_roles", "alice", tenant="default"), + "GET /facts/role_assignments", + "/facts/role_assignments", + page(user="alice", tenant="default"), + response=[], + ), + # tenants + Case(call("api.tenants.list"), "GET /facts/tenants", "/facts/tenants", page(), response=[]), + Case( + call("api.tenants.list_tenant_users", "acme"), + "GET /facts/tenants/{tenant_id}/users", + "/facts/tenants/acme/users", + page(), + response=EMPTY_PAGE, + ), + # Goes to the API with or without proxy_facts_via_pdp. It carries the PDP's headers + # all the same, as every request of a client with proxy_facts_via_pdp does; the API + # ignores them. + Case( + call("api.tenants.create_user", "acme", NEW_USER), + "POST /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users", + f"{FACTS}/tenants/acme/users", + body=NEW_USER, + response=USER, + ), + Case( + call("api.tenants.get", "acme"), + "GET /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + response=TENANT, + ), + Case( + call("api.tenants.get_by_key", "acme"), + "GET /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + response=TENANT, + ), + Case( + call("api.tenants.get_by_id", TENANT_ID), + "GET /facts/tenants/{tenant_id}", + f"/facts/tenants/{TENANT_ID}", + response=TENANT, + ), + Case( + call("api.tenants.create", NEW_TENANT), + "POST /facts/tenants", + "/facts/tenants", + body=NEW_TENANT, + response=TENANT, + ), + Case( + call("api.tenants.update", "acme", {"name": "Acme Inc"}), + "PATCH /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + body={"name": "Acme Inc"}, + response=TENANT, + ), + Case( + call("api.tenants.delete", "acme"), + "DELETE /facts/tenants/{tenant_id}", + "/facts/tenants/acme", + ), + Case( + call("api.tenants.delete_tenant_user", "acme", "alice"), + "DELETE /facts/tenants/{tenant_id}/users/{user_id}", + "/facts/tenants/acme/users/alice", + ), + Case( + call("api.tenants.bulk_create", [NEW_TENANT]), + "POST /facts/bulk/tenants", + "/facts/bulk/tenants", + body={"operations": [NEW_TENANT]}, + response={}, + ), + Case( + call("api.tenants.bulk_delete", ["acme"]), + "DELETE /facts/bulk/tenants", + "/facts/bulk/tenants", + body={"idents": ["acme"]}, + response={}, + ), + # role assignments + Case( + call("api.role_assignments.list", user_key="alice"), + "GET /facts/role_assignments", + "/facts/role_assignments", + page(user="alice"), + response=[], + ), + Case( + call("api.role_assignments.list_detailed", user_key="alice"), + "GET /facts/role_assignments/detailed", + "/facts/role_assignments/detailed", + page(user="alice"), + response=EMPTY_PAGE, + ), + Case( + call("api.role_assignments.assign", ASSIGNMENT), + "POST /facts/role_assignments", + "/facts/role_assignments", + body=ASSIGNMENT, + response=ROLE_ASSIGNMENT, + ), + Case( + call("api.role_assignments.unassign", ASSIGNMENT), + "DELETE /facts/role_assignments", + "/facts/role_assignments", + body=ASSIGNMENT, + ), + Case( + call("api.role_assignments.bulk_assign", [ASSIGNMENT]), + "POST /facts/role_assignments/bulk", + "/facts/role_assignments/bulk", + body=[ASSIGNMENT], + response={}, + ), + Case( + call("api.role_assignments.bulk_unassign", [ASSIGNMENT]), + "DELETE /facts/role_assignments/bulk", + "/facts/role_assignments/bulk", + body=[ASSIGNMENT], + response={}, + ), + # resource instances + Case( + call("api.resource_instances.list"), + "GET /facts/resource_instances", + "/facts/resource_instances", + page(), + response=[], + ), + Case( + call("api.resource_instances.list_detailed"), + "GET /facts/resource_instances/detailed", + "/facts/resource_instances/detailed", + page(), + response=EMPTY_PAGE, + ), + Case( + call("api.resource_instances.get", "document:readme"), + "GET /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.get_by_key", "document:readme"), + "GET /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.get_by_id", INSTANCE_ID), + "GET /facts/resource_instances/{instance_id}", + f"/facts/resource_instances/{INSTANCE_ID}", + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.create", INSTANCE), + "POST /facts/resource_instances", + "/facts/resource_instances", + body=INSTANCE, + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.update", "document:readme", {"attributes": {"pages": 3}}), + "PATCH /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + body={"attributes": {"pages": 3}}, + response=RESOURCE_INSTANCE, + ), + Case( + call("api.resource_instances.delete", "document:readme"), + "DELETE /facts/resource_instances/{instance_id}", + "/facts/resource_instances/document:readme", + ), + Case( + call("api.resource_instances.bulk_replace", [INSTANCE]), + "PUT /facts/bulk/resource_instances", + "/facts/bulk/resource_instances", + body={"operations": [INSTANCE]}, + response={}, + ), + Case( + call("api.resource_instances.bulk_delete", ["document:readme"]), + "DELETE /facts/bulk/resource_instances", + "/facts/bulk/resource_instances", + body={"idents": ["document:readme"]}, + response={}, + ), + # relationship tuples + Case( + call("api.relationship_tuples.list"), + "GET /facts/relationship_tuples", + "/facts/relationship_tuples", + page(), + response=[], + ), + Case( + call("api.relationship_tuples.list_detailed"), + "GET /facts/relationship_tuples/detailed", + "/facts/relationship_tuples/detailed", + page(), + response=EMPTY_PAGE, + ), + Case( + call("api.relationship_tuples.create", TUPLE), + "POST /facts/relationship_tuples", + "/facts/relationship_tuples", + body=TUPLE, + response=RELATIONSHIP_TUPLE, + ), + Case( + call("api.relationship_tuples.delete", TUPLE_IDENT), + "DELETE /facts/relationship_tuples", + "/facts/relationship_tuples", + body=TUPLE_IDENT, + ), + Case( + call("api.relationship_tuples.bulk_create", [TUPLE]), + "POST /facts/relationship_tuples/bulk", + "/facts/relationship_tuples/bulk", + body={"operations": [TUPLE]}, + response={}, + ), + Case( + call("api.relationship_tuples.bulk_delete", [TUPLE_IDENT]), + "DELETE /facts/relationship_tuples/bulk", + "/facts/relationship_tuples/bulk", + body={"idents": [TUPLE_IDENT]}, + response={}, + ), + ] +} + + +def on_pdp(case: Case) -> bool: + """Whether the method sends its request to the PDP rather than to the API.""" + return case.route.split(" ")[1].startswith("/facts/") diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index e9544f65..c80d19eb 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -22,7 +22,7 @@ import warnings from collections.abc import Iterator from operator import attrgetter -from typing import Any, Literal, NamedTuple +from typing import Any, Literal import aiohttp import pytest @@ -33,7 +33,8 @@ from permit.config import PermitConfig from permit.exceptions import PermitApiError, PermitNotFoundError from permit.sync import Permit as SyncPermit -from tests.utils import Call, call, sent +from tests.facts_methods import ALIASES, CASES, NEW_USER, Case, on_pdp, page +from tests.utils import CLOUD_PDP_URL, Call, call, sent FLAVOURS = ["async", "sync"] CLOUD_PDP_HOST = "cloudpdp.api.permit.io" @@ -137,122 +138,19 @@ def test_get_user_tenants_names_the_route_and_asks_for_a_container_pdp( # --- facts through the PDP ------------------------------------------------------------ -class FactsCase(NamedTuple): - """A facts method's call, and the one request it sends to the PDP's /facts routes.""" - - call: Call - method: str - path: str - query: list[tuple[str, str]] - body: Any - - -USER = {"key": "alice"} -TENANT = {"key": "t1", "name": "T1"} -ASSIGNMENT = {"user": "alice", "role": "viewer", "tenant": "t1"} -INSTANCE = {"key": "doc-1", "resource": "document", "tenant": "t1"} -TUPLE = {"subject": "folder:f1", "relation": "parent", "object": "document:doc-1"} -PAGE = [("page", "1"), ("per_page", "100")] - -# Facts methods of each API class, over each HTTP verb. The API coverage report fails on a -# request that neither the PDP's spec nor an `sdk_only` entry of -# .github/scripts/api_coverage_allowlist.json accounts for, so these send only such requests. -FACTS_CASES = { - "users.create": FactsCase(call("api.users.create", USER), "POST", "/facts/users", [], USER), - "users.update": FactsCase( - call("api.users.update", "alice", {"first_name": "Alice"}), - "PATCH", - "/facts/users/alice", - [], - {"first_name": "Alice"}, - ), - "users.assign_role": FactsCase( - call("api.users.assign_role", ASSIGNMENT), - "POST", - "/facts/users/alice/roles", - [], - {"role": "viewer", "tenant": "t1"}, - ), - "users.bulk_create": FactsCase( - call("api.users.bulk_create", [USER]), - "POST", - "/facts/bulk/users", - [], - {"operations": [USER]}, - ), - "tenants.create": FactsCase( - call("api.tenants.create", TENANT), "POST", "/facts/tenants", [], TENANT - ), - "tenants.delete": FactsCase( - call("api.tenants.delete", "t1"), "DELETE", "/facts/tenants/t1", [], None - ), - "tenants.delete_tenant_user": FactsCase( - call("api.tenants.delete_tenant_user", "t1", "alice"), - "DELETE", - "/facts/tenants/t1/users/alice", - [], - None, - ), - "tenants.bulk_create": FactsCase( - call("api.tenants.bulk_create", [TENANT]), - "POST", - "/facts/bulk/tenants", - [], - {"operations": [TENANT]}, - ), - "role_assignments.assign": FactsCase( - call("api.role_assignments.assign", ASSIGNMENT), - "POST", - "/facts/role_assignments", - [], - ASSIGNMENT, - ), - "role_assignments.list_detailed": FactsCase( - call("api.role_assignments.list_detailed", user_key="alice"), - "GET", - "/facts/role_assignments/detailed", - [*PAGE, ("user", "alice")], - None, - ), - "resource_instances.create": FactsCase( - call("api.resource_instances.create", INSTANCE), - "POST", - "/facts/resource_instances", - [], - INSTANCE, - ), - "resource_instances.bulk_replace": FactsCase( - call("api.resource_instances.bulk_replace", [INSTANCE]), - "PUT", - "/facts/bulk/resource_instances", - [], - {"operations": [INSTANCE]}, - ), - "relationship_tuples.create": FactsCase( - call("api.relationship_tuples.create", TUPLE), - "POST", - "/facts/relationship_tuples", - [], - TUPLE, - ), - "relationship_tuples.list_detailed": FactsCase( - call("api.relationship_tuples.list_detailed"), - "GET", - "/facts/relationship_tuples/detailed", - PAGE, - None, - ), -} +# The facts methods that send their request to the PDP with proxy_facts_via_pdp on: all but +# tenants.create_user(), which always goes to the API. +PDP_CASES = {name: case for name, case in CASES.items() if on_pdp(case)} @pytest.mark.parametrize("flavour", FLAVOURS) -@pytest.mark.parametrize("case", FACTS_CASES.values(), ids=FACTS_CASES.keys()) +@pytest.mark.parametrize("case", PDP_CASES.values(), ids=PDP_CASES.keys()) def test_a_facts_method_raises_the_cloud_pdp_404_as_an_api_error_that_asks_for_a_container_pdp( *, httpserver: HTTPServer, pdp_server: HTTPServer, split_config: PermitConfig, - case: FactsCase, + case: Case, flavour: str, ) -> None: split_config.proxy_facts_via_pdp = True @@ -269,9 +167,7 @@ def test_a_facts_method_raises_the_cloud_pdp_404_as_an_api_error_that_asks_for_a assert raised.value.message == message assert raised.value.details == {"details": "", "message": message} assert raised.value.status_code == 404 - assert [sent(request) for request, _ in pdp_server.log] == [ - {"method": case.method, "path": case.path, "query": case.query, "body": case.body} - ] + assert [sent(request) for request, _ in pdp_server.log] == [case.request] assert [sent_headers(request) for request, _ in pdp_server.log] == [JSON_HEADERS] assert httpserver.log == [] @@ -280,14 +176,14 @@ def test_a_facts_method_raises_the_cloud_pdp_404_as_an_api_error_that_asks_for_a "id": "request-1", "title": "The requested data was not found", "error_code": "NOT_FOUND", - "message": "Tenant with key 't1' was not found.", + "message": "Tenant with key 'acme' was not found.", } @pytest.mark.parametrize("flavour", FLAVOURS) @pytest.mark.parametrize( "case", - [FACTS_CASES["tenants.delete"], FACTS_CASES["users.update"]], + [CASES["tenants.delete"], CASES["users.update"]], ids=["tenants.delete", "users.update"], ) def test_the_apis_404_through_a_container_pdp_keeps_its_not_found_error( @@ -295,7 +191,7 @@ def test_the_apis_404_through_a_container_pdp_keeps_its_not_found_error( httpserver: HTTPServer, pdp_server: HTTPServer, split_config: PermitConfig, - case: FactsCase, + case: Case, flavour: str, ) -> None: """A container PDP's /facts routes pass on the API's 404 for an object that is missing.""" @@ -310,7 +206,7 @@ def test_the_apis_404_through_a_container_pdp_keeps_its_not_found_error( assert type(raised.value) is PermitNotFoundError assert str(raised.value) == ( f"The requested data was not found ({ErrorCode.NOT_FOUND})\n" - "Tenant with key 't1' was not found.\n" + "Tenant with key 'acme' was not found.\n" "For more information: https://permit-io.slack.com/ssb/redirect (Request ID: request-1)" ) assert raised.value.details == NOT_FOUND_DETAILS @@ -322,7 +218,7 @@ def test_the_apis_404_through_a_container_pdp_keeps_its_not_found_error( @pytest.mark.parametrize( ("proxy_facts_via_pdp", "path", "target"), [ - (True, "/facts/users", call("api.users.create", USER)), + (True, "/facts/users", CASES["users.create"].call), (False, "/local/role_assignments", call("pdp_api.role_assignments.list")), ], ids=["facts", "pdp_api"], @@ -357,12 +253,12 @@ def test_the_apis_empty_404_keeps_its_api_error_with_proxy_facts_via_pdp_off( httpserver.expect_request(path, method="POST").respond_with_data("", status=404) with pytest.raises(PermitApiError) as raised: - invoke(split_config, flavour, call("api.users.create", USER)) + invoke(split_config, flavour, CASES["users.create"].call) assert type(raised.value) is PermitApiError assert str(raised.value) == "404 API Error: {'details': ''}" assert [sent(request) for request, _ in httpserver.log] == [ - {"method": "POST", "path": path, "query": [], "body": USER} + {"method": "POST", "path": path, "query": [], "body": NEW_USER} ] assert pdp_server.log == [] @@ -394,7 +290,7 @@ def test_pdp_api_raises_the_cloud_pdp_404_as_an_api_error_that_asks_for_a_contai { "method": "GET", "path": LOCAL_ROLE_ASSIGNMENTS, - "query": [*PAGE, ("user", "alice")], + "query": list(page(user="alice")), "body": None, } ] @@ -441,71 +337,12 @@ def test_any_404_from_the_cloud_pdps_host_asks_for_a_container_pdp( # --- docstrings ----------------------------------------------------------------------- -# With proxy_facts_via_pdp on, every public method of these APIs sends its request to the -# PDP's /facts routes, except tenants.create_user() and its deprecated alias add_user(), -# which always go to the Permit REST API. -FACTS_APIS = ("users", "tenants", "role_assignments", "resource_instances", "relationship_tuples") -API_ONLY = ("api.tenants.create_user", "api.tenants.add_user") -FACTS_METHODS = ( - *( - f"api.users.{name}" - for name in ( - "assign_role", - "bulk_create", - "bulk_delete", - "bulk_replace", - "create", - "delete", - "get", - "get_assigned_roles", - "get_by_id", - "get_by_key", - "list", - "sync", - "unassign_role", - "update", - ) - ), - *( - f"api.tenants.{name}" - for name in ( - "bulk_create", - "bulk_delete", - "create", - "delete", - "delete_tenant_user", - "get", - "get_by_id", - "get_by_key", - "list", - "list_tenant_users", - "update", - ) - ), - *( - f"api.role_assignments.{name}" - for name in ("assign", "bulk_assign", "bulk_unassign", "list", "list_detailed", "unassign") - ), - *( - f"api.resource_instances.{name}" - for name in ( - "bulk_delete", - "bulk_replace", - "create", - "delete", - "get", - "get_by_id", - "get_by_key", - "list", - "list_detailed", - "update", - ) - ), - *( - f"api.relationship_tuples.{name}" - for name in ("bulk_create", "bulk_delete", "create", "delete", "list", "list_detailed") - ), -) +# The facts methods that send their request to the PDP's /facts routes with +# proxy_facts_via_pdp on, and those that always go to the Permit REST API. +FACTS_METHODS = [f"api.{name}" for name in PDP_CASES] +API_ONLY = [ + f"api.{name}" for name in [*CASES, *ALIASES] if not on_pdp(CASES[ALIASES.get(name, name)]) +] FACTS_NOTE = ( "Container PDP only with ``proxy_facts_via_pdp`` on: the request then goes to the PDP's " "``/facts`` routes, which the cloud PDP does not serve. It answers 404, which this method " @@ -541,19 +378,6 @@ def docstring(client: Permit, path: str) -> str: return " ".join((inspect.getdoc(attrgetter(path)(client)) or "").split()) -def test_the_facts_methods_are_every_public_method_of_the_facts_apis_but_create_user( - client: Permit, -) -> None: - public = { - f"api.{api}.{name}" - for api in FACTS_APIS - for name in dir(getattr(client.api, api)) - if not name.startswith("_") and callable(getattr(getattr(client.api, api), name)) - } - - assert sorted(public - set(API_ONLY)) == sorted(FACTS_METHODS) - - @pytest.mark.parametrize("path", NOTES.keys()) def test_a_container_pdp_only_method_says_so_in_its_docstring(client: Permit, path: str) -> None: assert NOTES[path] in docstring(client, path) @@ -569,9 +393,6 @@ def test_a_facts_api_method_that_always_goes_to_the_api_does_not_say_container_p # --- the warning at creation ---------------------------------------------------------- -CLOUD_PDP_URL = f"https://{CLOUD_PDP_HOST}" - - def facts_proxied_to_the_cloud_pdp(pdp_url: str) -> str: return ( "proxy_facts_via_pdp is on, so the facts methods of permit.api send their requests to " @@ -698,12 +519,10 @@ def test_facts_through_the_cloud_pdps_host_warn_then_ask_for_a_container_pdp( pytest.warns(UserWarning, match="^proxy_facts_via_pdp is on") as caught, pytest.raises(PermitApiError) as raised, ): - invoke(split_config, flavour, call("api.users.create", USER)) + invoke(split_config, flavour, CASES["users.create"].call) assert [str(w.message) for w in caught] == [facts_proxied_to_the_cloud_pdp(cloud_pdp_url)] message = container_pdp_only("POST /facts/users", cloud_pdp_url, USE_A_CONTAINER_PDP_FOR_FACTS) assert type(raised.value) is PermitApiError assert str(raised.value) == message - assert [sent(request) for request, _ in pdp_server.log] == [ - {"method": "POST", "path": "/facts/users", "query": [], "body": USER} - ] + assert [sent(request) for request, _ in pdp_server.log] == [CASES["users.create"].request] diff --git a/tests/test_facts_sync_offline.py b/tests/test_facts_sync_offline.py index 60816c68..91f88e10 100644 --- a/tests/test_facts_sync_offline.py +++ b/tests/test_facts_sync_offline.py @@ -20,20 +20,16 @@ from contextlib import nullcontext from operator import attrgetter from pathlib import Path -from typing import Any, NamedTuple +from typing import Any import pytest from pytest_httpserver import HTTPServer from werkzeug import Request from permit import Permit -from permit.api.relationship_tuples import RelationshipTuplesApi -from permit.api.resource_instances import ResourceInstancesApi -from permit.api.role_assignments import RoleAssignmentsApi -from permit.api.tenants import TenantsApi -from permit.api.users import UsersApi from permit.config import PermitConfig from permit.sync import Permit as SyncPermit +from tests.facts_methods import ALIASES, CASES, FACTS_APIS, USER, Case, on_pdp from tests.utils import FACTS, Call, call, offline_config, sent FLAVOURS = ["async", "sync"] @@ -43,42 +39,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1] PDP_SPEC = REPO_ROOT / ".github" / "api-specs" / "pdp.json" -USER_ID = "6a1b2c3d-0000-4000-8000-000000000010" -TENANT_ID = "6a1b2c3d-0000-4000-8000-000000000020" -INSTANCE_ID = "6a1b2c3d-0000-4000-8000-000000000030" - - -def read(**fields: Any) -> dict[str, Any]: - """A read model's JSON: ``fields``, and the ids and timestamps every read model has.""" - return { - "id": "6a1b2c3d-0000-4000-8000-000000000000", - "organization_id": "6a1b2c3d-0000-4000-8000-000000000001", - "project_id": "6a1b2c3d-0000-4000-8000-000000000002", - "environment_id": "6a1b2c3d-0000-4000-8000-000000000003", - "created_at": "2026-01-01T00:00:00+00:00", - "updated_at": "2026-01-01T00:00:00+00:00", - **fields, - } - - -USER = read(key="alice", id=USER_ID) -TENANT = read(key="acme", name="Acme", id=TENANT_ID, last_action_at="2026-01-01T00:00:00+00:00") -ROLE_ASSIGNMENT = read( - user="alice", role="editor", user_id=USER_ID, role_id=USER_ID, tenant_id=TENANT_ID -) -RESOURCE_INSTANCE = read( - key="readme", resource="document", tenant="default", resource_id=USER_ID, tenant_id=TENANT_ID -) -RELATIONSHIP_TUPLE = read( - subject="folder:docs", - relation="parent", - object="document:readme", - tenant="default", - subject_id=INSTANCE_ID, - relation_id=INSTANCE_ID, - tenant_id=TENANT_ID, -) - CREATE_USER = call("api.users.create", {"key": "alice"}) CREATE_USER_SENT = {"method": "POST", "path": "/facts/users", "query": [], "body": {"key": "alice"}} @@ -269,371 +229,6 @@ def test_without_proxy_facts_via_pdp_a_zero_timeout_sends_no_header( } ) -# The facts APIs, which send their requests to the PDP with proxy_facts_via_pdp on. -FACTS_APIS: dict[str, type] = { - "users": UsersApi, - "tenants": TenantsApi, - "role_assignments": RoleAssignmentsApi, - "resource_instances": ResourceInstancesApi, - "relationship_tuples": RelationshipTuplesApi, -} - -# Public methods with no case of their own: a deprecated alias calls the method named here. -ALIASES = {"tenants.add_user": "tenants.create_user"} - - -class Case(NamedTuple): - """A facts method, called with ``proxy_facts_via_pdp`` on, and the one request it sends. - - ``route`` is the route the request is for, as " ": a PDP route - under ``/facts``, or the API route of a method that goes to the API either way. - ``response`` is the JSON the server answers with, or None for a 204 with no body. - """ - - call: Call - route: str - path: str - query: tuple[tuple[str, str], ...] = () - body: Any = None - response: Any = None - - -def page(**filters: str) -> tuple[tuple[str, str], ...]: - """The query string of a list request for the first page, sorted as ``sent()`` sorts it.""" - return tuple(sorted({"page": "1", "per_page": "100", **filters}.items())) - - -EMPTY_PAGE = {"data": [], "total_count": 0} -NEW_USER = {"key": "alice"} -NEW_TENANT = {"key": "acme", "name": "Acme"} -ASSIGNMENT = {"user": "alice", "role": "editor", "tenant": "default"} -INSTANCE = {"key": "readme", "resource": "document", "tenant": "default"} -TUPLE_IDENT = {"subject": "folder:docs", "relation": "parent", "object": "document:readme"} -TUPLE = {**TUPLE_IDENT, "tenant": "default"} - -CASES = { - case.call.path.removeprefix("api."): case - for case in [ - # users - Case( - call("api.users.list"), "GET /facts/users", "/facts/users", page(), response=EMPTY_PAGE - ), - Case( - call("api.users.get", "alice"), - "GET /facts/users/{user_id}", - "/facts/users/alice", - response=USER, - ), - Case( - call("api.users.get_by_key", "alice"), - "GET /facts/users/{user_id}", - "/facts/users/alice", - response=USER, - ), - Case( - call("api.users.get_by_id", USER_ID), - "GET /facts/users/{user_id}", - f"/facts/users/{USER_ID}", - response=USER, - ), - Case( - call("api.users.create", NEW_USER), - "POST /facts/users", - "/facts/users", - body=NEW_USER, - response=USER, - ), - Case( - call("api.users.update", "alice", {"first_name": "Alice"}), - "PATCH /facts/users/{user_id}", - "/facts/users/alice", - body={"first_name": "Alice"}, - response=USER, - ), - Case( - call("api.users.sync", NEW_USER), - "PUT /facts/users/{user_id}", - "/facts/users/alice", - body=NEW_USER, - response=USER, - ), - Case( - call("api.users.delete", "alice"), "DELETE /facts/users/{user_id}", "/facts/users/alice" - ), - Case( - call("api.users.bulk_create", [NEW_USER]), - "POST /facts/bulk/users", - "/facts/bulk/users", - body={"operations": [NEW_USER]}, - response={}, - ), - Case( - call("api.users.bulk_replace", [NEW_USER]), - "PUT /facts/bulk/users", - "/facts/bulk/users", - body={"operations": [NEW_USER]}, - response={}, - ), - Case( - call("api.users.bulk_delete", ["alice"]), - "DELETE /facts/bulk/users", - "/facts/bulk/users", - body={"idents": ["alice"]}, - response={}, - ), - Case( - call("api.users.assign_role", ASSIGNMENT), - "POST /facts/users/{user_id}/roles", - "/facts/users/alice/roles", - body={"role": "editor", "tenant": "default"}, - response=ROLE_ASSIGNMENT, - ), - Case( - call("api.users.unassign_role", ASSIGNMENT), - "DELETE /facts/users/{user_id}/roles", - "/facts/users/alice/roles", - body={"role": "editor", "tenant": "default"}, - ), - Case( - call("api.users.get_assigned_roles", "alice", tenant="default"), - "GET /facts/role_assignments", - "/facts/role_assignments", - page(user="alice", tenant="default"), - response=[], - ), - # tenants - Case(call("api.tenants.list"), "GET /facts/tenants", "/facts/tenants", page(), response=[]), - Case( - call("api.tenants.list_tenant_users", "acme"), - "GET /facts/tenants/{tenant_id}/users", - "/facts/tenants/acme/users", - page(), - response=EMPTY_PAGE, - ), - # Goes to the API with or without proxy_facts_via_pdp. It carries the PDP's headers - # all the same, as every request of a client with proxy_facts_via_pdp does; the API - # ignores them. - Case( - call("api.tenants.create_user", "acme", NEW_USER), - "POST /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users", - f"{FACTS}/tenants/acme/users", - body=NEW_USER, - response=USER, - ), - Case( - call("api.tenants.get", "acme"), - "GET /facts/tenants/{tenant_id}", - "/facts/tenants/acme", - response=TENANT, - ), - Case( - call("api.tenants.get_by_key", "acme"), - "GET /facts/tenants/{tenant_id}", - "/facts/tenants/acme", - response=TENANT, - ), - Case( - call("api.tenants.get_by_id", TENANT_ID), - "GET /facts/tenants/{tenant_id}", - f"/facts/tenants/{TENANT_ID}", - response=TENANT, - ), - Case( - call("api.tenants.create", NEW_TENANT), - "POST /facts/tenants", - "/facts/tenants", - body=NEW_TENANT, - response=TENANT, - ), - Case( - call("api.tenants.update", "acme", {"name": "Acme Inc"}), - "PATCH /facts/tenants/{tenant_id}", - "/facts/tenants/acme", - body={"name": "Acme Inc"}, - response=TENANT, - ), - Case( - call("api.tenants.delete", "acme"), - "DELETE /facts/tenants/{tenant_id}", - "/facts/tenants/acme", - ), - Case( - call("api.tenants.delete_tenant_user", "acme", "alice"), - "DELETE /facts/tenants/{tenant_id}/users/{user_id}", - "/facts/tenants/acme/users/alice", - ), - Case( - call("api.tenants.bulk_create", [NEW_TENANT]), - "POST /facts/bulk/tenants", - "/facts/bulk/tenants", - body={"operations": [NEW_TENANT]}, - response={}, - ), - Case( - call("api.tenants.bulk_delete", ["acme"]), - "DELETE /facts/bulk/tenants", - "/facts/bulk/tenants", - body={"idents": ["acme"]}, - response={}, - ), - # role assignments - Case( - call("api.role_assignments.list", user_key="alice"), - "GET /facts/role_assignments", - "/facts/role_assignments", - page(user="alice"), - response=[], - ), - Case( - call("api.role_assignments.list_detailed", user_key="alice"), - "GET /facts/role_assignments/detailed", - "/facts/role_assignments/detailed", - page(user="alice"), - response=EMPTY_PAGE, - ), - Case( - call("api.role_assignments.assign", ASSIGNMENT), - "POST /facts/role_assignments", - "/facts/role_assignments", - body=ASSIGNMENT, - response=ROLE_ASSIGNMENT, - ), - Case( - call("api.role_assignments.unassign", ASSIGNMENT), - "DELETE /facts/role_assignments", - "/facts/role_assignments", - body=ASSIGNMENT, - ), - Case( - call("api.role_assignments.bulk_assign", [ASSIGNMENT]), - "POST /facts/role_assignments/bulk", - "/facts/role_assignments/bulk", - body=[ASSIGNMENT], - response={}, - ), - Case( - call("api.role_assignments.bulk_unassign", [ASSIGNMENT]), - "DELETE /facts/role_assignments/bulk", - "/facts/role_assignments/bulk", - body=[ASSIGNMENT], - response={}, - ), - # resource instances - Case( - call("api.resource_instances.list"), - "GET /facts/resource_instances", - "/facts/resource_instances", - page(), - response=[], - ), - Case( - call("api.resource_instances.list_detailed"), - "GET /facts/resource_instances/detailed", - "/facts/resource_instances/detailed", - page(), - response=EMPTY_PAGE, - ), - Case( - call("api.resource_instances.get", "document:readme"), - "GET /facts/resource_instances/{instance_id}", - "/facts/resource_instances/document:readme", - response=RESOURCE_INSTANCE, - ), - Case( - call("api.resource_instances.get_by_key", "document:readme"), - "GET /facts/resource_instances/{instance_id}", - "/facts/resource_instances/document:readme", - response=RESOURCE_INSTANCE, - ), - Case( - call("api.resource_instances.get_by_id", INSTANCE_ID), - "GET /facts/resource_instances/{instance_id}", - f"/facts/resource_instances/{INSTANCE_ID}", - response=RESOURCE_INSTANCE, - ), - Case( - call("api.resource_instances.create", INSTANCE), - "POST /facts/resource_instances", - "/facts/resource_instances", - body=INSTANCE, - response=RESOURCE_INSTANCE, - ), - Case( - call("api.resource_instances.update", "document:readme", {"attributes": {"pages": 3}}), - "PATCH /facts/resource_instances/{instance_id}", - "/facts/resource_instances/document:readme", - body={"attributes": {"pages": 3}}, - response=RESOURCE_INSTANCE, - ), - Case( - call("api.resource_instances.delete", "document:readme"), - "DELETE /facts/resource_instances/{instance_id}", - "/facts/resource_instances/document:readme", - ), - Case( - call("api.resource_instances.bulk_replace", [INSTANCE]), - "PUT /facts/bulk/resource_instances", - "/facts/bulk/resource_instances", - body={"operations": [INSTANCE]}, - response={}, - ), - Case( - call("api.resource_instances.bulk_delete", ["document:readme"]), - "DELETE /facts/bulk/resource_instances", - "/facts/bulk/resource_instances", - body={"idents": ["document:readme"]}, - response={}, - ), - # relationship tuples - Case( - call("api.relationship_tuples.list"), - "GET /facts/relationship_tuples", - "/facts/relationship_tuples", - page(), - response=[], - ), - Case( - call("api.relationship_tuples.list_detailed"), - "GET /facts/relationship_tuples/detailed", - "/facts/relationship_tuples/detailed", - page(), - response=EMPTY_PAGE, - ), - Case( - call("api.relationship_tuples.create", TUPLE), - "POST /facts/relationship_tuples", - "/facts/relationship_tuples", - body=TUPLE, - response=RELATIONSHIP_TUPLE, - ), - Case( - call("api.relationship_tuples.delete", TUPLE_IDENT), - "DELETE /facts/relationship_tuples", - "/facts/relationship_tuples", - body=TUPLE_IDENT, - ), - Case( - call("api.relationship_tuples.bulk_create", [TUPLE]), - "POST /facts/relationship_tuples/bulk", - "/facts/relationship_tuples/bulk", - body={"operations": [TUPLE]}, - response={}, - ), - Case( - call("api.relationship_tuples.bulk_delete", [TUPLE_IDENT]), - "DELETE /facts/relationship_tuples/bulk", - "/facts/relationship_tuples/bulk", - body={"idents": [TUPLE_IDENT]}, - response={}, - ), - ] -} - - -def on_pdp(case: Case) -> bool: - """Whether the method sends its request to the PDP rather than to the API.""" - return case.route.split(" ")[1].startswith("/facts/") - def route_matches(route: str, method: str, path: str) -> bool: """Whether a request for ``method`` and ``path`` is one for ``route``.""" @@ -685,9 +280,8 @@ def test_a_proxied_facts_method_sends_the_sync_headers_to_its_route( facts_sync_timeout=2.5, facts_sync_timeout_policy="fail", ) - method = case.route.split(" ")[0] server, other = (pdp_server, httpserver) if on_pdp(case) else (httpserver, pdp_server) - handler = server.expect_request(case.path, method=method) + handler = server.expect_request(case.path, method=case.method) if case.response is None: handler.respond_with_data("", status=204) else: @@ -697,12 +291,7 @@ def test_a_proxied_facts_method_sends_the_sync_headers_to_its_route( [(request, _)] = server.log assert route_matches(case.route, request.method, request.path) - assert sent(request) == { - "method": method, - "path": case.path, - "query": list(case.query), - "body": case.body, - } + assert sent(request) == case.request assert sent_headers(request) == facts_headers("2.5", "fail") assert other.log == [] From 24194625b0c3508a27d307d901525faf7b80b9c8 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:47:22 +0300 Subject: [PATCH 12/20] Say what the facts proxy does on the cloud PDP in its docs The README's read-your-writes section and the proxy_facts_via_pdp description said only that the cloud PDP answers 404. They now say that the SDK raises that 404 as a PermitApiError naming the route, and that a client created with the facts proxy on and the cloud PDP as its pdp issues a UserWarning. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++++-- permit/config.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e93f8b96..c4ac9656 100644 --- a/README.md +++ b/README.md @@ -192,8 +192,11 @@ print(refreshed.update_id, refreshed.pdp_ids) With `proxy_facts_via_pdp=True`, the facts methods of `permit.api`, those of its `users`, `tenants`, `role_assignments`, `resource_instances` and `relationship_tuples` APIs, send their requests to the PDP, which forwards them to the Permit REST API. Only the container PDP serves -them: the cloud PDP answers 404. On some of these writes, the PDP also waits until the change -is in its own data before it answers, so that a check sent next sees the change: +them: the cloud PDP answers 404, which the SDK raises as a `PermitApiError` that names the route +and says it needs the container PDP. A client created with `proxy_facts_via_pdp=True` and the +cloud PDP's address as `pdp` issues a `UserWarning` that says so. On some of these writes, the +PDP also waits until the change is in its own data before it answers, so that a check sent next +sees the change: ```py permit = Permit(token="", pdp="http://localhost:7766", proxy_facts_via_pdp=True) diff --git a/permit/config.py b/permit/config.py index 1a7ee3c8..ecfc76ab 100644 --- a/permit/config.py +++ b/permit/config.py @@ -114,12 +114,14 @@ class PermitConfig(BaseModel): description="Send the facts requests of permit.api, those of its users, tenants, " "role_assignments, resource_instances and relationship_tuples APIs, to the PDP, which " "forwards them to the Permit REST API. Only the container PDP serves them: the cloud " - "PDP answers 404. The PDP waits until it has the change before it answers on the " - "writes of users.create(), users.update(), users.sync(), users.assign_role(), " - "users.unassign_role(), tenants.create(), role_assignments.assign(), " - "role_assignments.unassign(), resource_instances.create(), resource_instances.update() " - "and relationship_tuples.create() only, for up to facts_sync_timeout seconds, or its " - "own default when that is None. It forwards every other facts request without waiting, " + "PDP answers 404, which the SDK raises as a PermitApiError that says so, and a client " + "created with pdp set to the cloud PDP's address issues a UserWarning. The PDP waits " + "until it has the change before it answers on the writes of users.create(), " + "users.update(), users.sync(), users.assign_role(), users.unassign_role(), " + "tenants.create(), role_assignments.assign(), role_assignments.unassign(), " + "resource_instances.create(), resource_instances.update() and " + "relationship_tuples.create() only, for up to facts_sync_timeout seconds, or its own " + "default when that is None. It forwards every other facts request without waiting, " "such as users.delete(), tenants.update() and the bulk methods. tenants.create_user() " "always goes to the API.", ) From d374ee96e7dab9beba47317051da6c2d085376ac Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 05:48:32 +0300 Subject: [PATCH 13/20] Point contributors at the table of proxied facts requests CONTRIBUTING.md's API coverage section now says that tests/facts_methods.py pins the request of every facts method with proxy_facts_via_pdp on, that the wait and cloud-PDP 404 tests run on each, and that a new facts method needs a case there. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b5c8f03..90c77b58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -365,7 +365,11 @@ So when an offline test starts sending an allowlisted operation's request (the w of a new method for a `deferred` operation, or a new test for an `untested` one), its entry has to go in the same change. A method's wire test with `proxy_facts_via_pdp` on may also send a `/facts/...` request that the PDP forwards to the control plane but does not -list in its spec; that request needs an `undocumented` `sdk_only` entry. +list in its spec; that request needs an `undocumented` `sdk_only` entry. Every public +method of the facts APIs has such a test: `tests/facts_methods.py` pins the request each one +sends with the proxy on, the tests of the PDP's waits and of the cloud PDP's 404 run on each, +and a new facts method fails `test_every_public_facts_method_has_a_case` until it has a case +there. CI runs it in two places: From 840fd6dca27b797aba49b589a3ae4c02ba1f1c29 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 06:12:08 +0300 Subject: [PATCH 14/20] Count only a 404 with no body as the cloud PDP's The cloud PDP answers a route it does not serve with a 404 of zero bytes. A 404 whose body is only whitespace now keeps the error it raised before, as the docstring already said, and a test pins it on a facts route and a pdp_api route (PER-16340). Co-Authored-By: Claude Opus 5.5 --- permit/utils/cloud_pdp.py | 8 ++--- tests/test_container_pdp_only_offline.py | 46 ++++++++++++++++++++---- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py index 96a73210..00616227 100644 --- a/permit/utils/cloud_pdp.py +++ b/permit/utils/cloud_pdp.py @@ -48,9 +48,9 @@ async def is_cloud_pdp_route_not_found(response: aiohttp.ClientResponse, pdp_url """Whether a PDP's ``response`` is the cloud PDP's 404 for a route it does not serve. It is when the status is 404 and either ``pdp_url`` is the cloud PDP's address or the - body is empty, which is how the cloud PDP answers such a route. A 404 with a body comes - from a container PDP, or from the API through a container PDP's ``/facts`` routes, and - is a real "not found". + body is empty, with no bytes at all, which is how the cloud PDP answers such a route. A + 404 with a body, even one of only whitespace, comes from a container PDP, or from the + API through a container PDP's ``/facts`` routes, and is a real "not found". Args: response: The PDP's response to a request for a route only the container PDP serves. @@ -64,7 +64,7 @@ async def is_cloud_pdp_route_not_found(response: aiohttp.ClientResponse, pdp_url return False if is_cloud_pdp(pdp_url): return True - return not (await response.read()).strip() + return not await response.read() def container_pdp_only_message( diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index c80d19eb..0babeb72 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -214,27 +214,34 @@ def test_the_apis_404_through_a_container_pdp_keeps_its_not_found_error( assert httpserver.log == [] -@pytest.mark.parametrize("flavour", FLAVOURS) -@pytest.mark.parametrize( - ("proxy_facts_via_pdp", "path", "target"), +# A route of each kind that only the container PDP serves, and a call that requests it. +container_pdp_only_routes = pytest.mark.parametrize( + ("proxy_facts_via_pdp", "method", "path", "target"), [ - (True, "/facts/users", CASES["users.create"].call), - (False, "/local/role_assignments", call("pdp_api.role_assignments.list")), + (True, "POST", "/facts/users", CASES["users.create"].call), + (False, "GET", "/local/role_assignments", call("pdp_api.role_assignments.list")), ], ids=["facts", "pdp_api"], ) + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@container_pdp_only_routes def test_a_container_pdps_own_404_keeps_the_api_error_it_raised( *, pdp_server: HTTPServer, split_config: PermitConfig, proxy_facts_via_pdp: bool, + method: str, path: str, target: Call, flavour: str, ) -> None: """A container PDP answers a route it does not serve with a JSON 404.""" split_config.proxy_facts_via_pdp = proxy_facts_via_pdp - pdp_server.expect_request(path).respond_with_json({"detail": "Not Found"}, status=404) + pdp_server.expect_request(path, method=method).respond_with_json( + {"detail": "Not Found"}, status=404 + ) with pytest.raises(PermitApiError) as raised: invoke(split_config, flavour, target) @@ -244,6 +251,33 @@ def test_a_container_pdps_own_404_keeps_the_api_error_it_raised( assert raised.value.details == {"detail": "Not Found"} +@pytest.mark.parametrize("flavour", FLAVOURS) +@container_pdp_only_routes +def test_a_404_whose_body_is_only_whitespace_keeps_the_api_error_it_raised( + *, + pdp_server: HTTPServer, + split_config: PermitConfig, + proxy_facts_via_pdp: bool, + method: str, + path: str, + target: Call, + flavour: str, +) -> None: + """Only a 404 with no body at all is the cloud PDP's, away from its address.""" + split_config.proxy_facts_via_pdp = proxy_facts_via_pdp + pdp_server.expect_request(path, method=method).respond_with_data( + " \n", status=404, content_type="text/plain" + ) + + with pytest.raises(PermitApiError) as raised: + invoke(split_config, flavour, target) + + assert type(raised.value) is PermitApiError + assert str(raised.value) == "404 API Error: {'details': ' \\n'}" + assert raised.value.details == {"details": " \n"} + assert len(pdp_server.log) == 1 + + @pytest.mark.parametrize("flavour", FLAVOURS) def test_the_apis_empty_404_keeps_its_api_error_with_proxy_facts_via_pdp_off( httpserver: HTTPServer, pdp_server: HTTPServer, split_config: PermitConfig, flavour: str From 04816d96d9b04147d6703e518f01eaaf6ca1153d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 06:12:24 +0300 Subject: [PATCH 15/20] Say why any 404 at the cloud PDP's address counts as its own The cloud PDP serves none of the routes only the container PDP serves, so every 404 it sends for one is a missing route. Say so where the rule is defined, so that a cloud PDP that starts to serve one of them gets its real "not found" told apart there (PER-16340). Co-Authored-By: Claude Opus 5.5 --- permit/utils/cloud_pdp.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py index 00616227..314b2558 100644 --- a/permit/utils/cloud_pdp.py +++ b/permit/utils/cloud_pdp.py @@ -52,6 +52,10 @@ async def is_cloud_pdp_route_not_found(response: aiohttp.ClientResponse, pdp_url 404 with a body, even one of only whitespace, comes from a container PDP, or from the API through a container PDP's ``/facts`` routes, and is a real "not found". + Any 404 from the cloud PDP's address counts, whatever its body, because the cloud PDP + serves none of these routes. If it starts to serve one, its real "not found" for that + route has to be told apart here. + Args: response: The PDP's response to a request for a route only the container PDP serves. pdp_url: The address of the PDP the request was sent to, as the SDK's ``pdp`` From 25cb8a6f7e9ddfe81d216bae5325f00a8a3b3ee7 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 06:13:27 +0300 Subject: [PATCH 16/20] Test which pdp addresses count as the cloud PDP's The warning at creation fires for any address on the cloud PDP's host, whatever its scheme, port, path or letter case, and not for an address on another host that only mentions it, one with no host, or one that cannot be parsed (PER-16340). Co-Authored-By: Claude Opus 5.5 --- tests/test_container_pdp_only_offline.py | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index 0babeb72..ecc41495 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -489,6 +489,53 @@ def test_no_warning_without_both_the_facts_proxy_and_the_cloud_pdp( assert caught == [] +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + "pdp", ["HTTPS://CloudPDP.API.permit.io:443/v1/", "http://cloudpdp.api.permit.io:7766"] +) +def test_any_address_on_the_cloud_pdps_host_warns( + config: PermitConfig, pdp: str, flavour: str +) -> None: + """The address's scheme, port, path and letter case do not matter, only its host.""" + config.pdp = pdp + config.proxy_facts_via_pdp = True + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = create(config, flavour) + close(client) + + assert [(w.category, str(w.message)) for w in caught] == [ + (UserWarning, facts_proxied_to_the_cloud_pdp(pdp)) + ] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + "pdp", + [ + "http://localhost:7766/cloudpdp.api.permit.io", + "https://cloudpdp.api.permit.io.example.com", + "https://example.com/?pdp=cloudpdp.api.permit.io", + "cloudpdp.api.permit.io", + "http://[::1", + ], +) +def test_an_address_on_another_host_does_not_warn( + config: PermitConfig, pdp: str, flavour: str +) -> None: + """Nor does one with no host, or one that cannot be parsed.""" + config.pdp = pdp + config.proxy_facts_via_pdp = True + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = create(config, flavour) + close(client) + + assert caught == [] + + @pytest.mark.parametrize("flavour", FLAVOURS) @pytest.mark.parametrize(("action", "shown"), [("always", 2), ("default", 1)]) def test_each_creation_warns_and_the_default_filter_shows_it_once_per_line( From 59a6824759cb07a82942872c9ae2be4cabd7a086 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 06:13:38 +0300 Subject: [PATCH 17/20] Document the details of the cloud PDP's 404 on PermitApiError The error for the cloud PDP's 404 on a route only the container PDP serves keeps the body's text under "details" and adds the message under "message", whatever the body. Say so in the body argument's docs (PER-16340). Co-Authored-By: Claude Opus 5.5 --- permit/exceptions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/permit/exceptions.py b/permit/exceptions.py index e5a4b549..070188ef 100644 --- a/permit/exceptions.py +++ b/permit/exceptions.py @@ -82,7 +82,9 @@ class PermitApiError(PermitError): Args: response: The error response. body: The response's JSON body, or for a body that is not JSON, - ``{"details": }``. + ``{"details": }``. For the cloud PDP's 404 on a route only the + container PDP serves, ``{"details": , "message": }``, + whatever the body. message: The error's message, in place of the one it builds from the status code and the body. """ From e973d0f3e0a11ef209e4ddf5b5771f889d869cb4 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 06:13:59 +0300 Subject: [PATCH 18/20] Give the cloud PDP warning the error's advice and docs link The warning at creation said what to do in other words than the 404 error does. It now ends with the same advice as that error, and with the link to the PDP setup docs (PER-16340). Co-Authored-By: Claude Opus 5.5 --- permit/utils/cloud_pdp.py | 5 +++-- tests/test_container_pdp_only_offline.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py index 314b2558..282af5cf 100644 --- a/permit/utils/cloud_pdp.py +++ b/permit/utils/cloud_pdp.py @@ -105,6 +105,7 @@ def facts_proxied_to_the_cloud_pdp(pdp_url: str) -> str: return ( f"proxy_facts_via_pdp is on, so the facts methods of permit.api send their requests to " f"the PDP's /facts routes, but pdp is the cloud PDP ({pdp_url}), which does not serve " - f"them: each of those requests will fail with status code 404. Point pdp at a " - f"container PDP, or turn proxy_facts_via_pdp off to send facts to the Permit REST API." + f"them: each of those requests will fail with status code 404.\n" + f"{USE_A_CONTAINER_PDP_FOR_FACTS}\n" + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py index ecc41495..bf72aadb 100644 --- a/tests/test_container_pdp_only_offline.py +++ b/tests/test_container_pdp_only_offline.py @@ -431,8 +431,9 @@ def facts_proxied_to_the_cloud_pdp(pdp_url: str) -> str: return ( "proxy_facts_via_pdp is on, so the facts methods of permit.api send their requests to " f"the PDP's /facts routes, but pdp is the cloud PDP ({pdp_url}), which does not serve " - "them: each of those requests will fail with status code 404. Point pdp at a container " - "PDP, or turn proxy_facts_via_pdp off to send facts to the Permit REST API." + "them: each of those requests will fail with status code 404.\n" + f"{USE_A_CONTAINER_PDP_FOR_FACTS}\n" + f"Read more about setting up the PDP at {DOCS_LINK}" ) From d76e3ac85631ca234aa5ebac77987edc26c53602 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 06:14:37 +0300 Subject: [PATCH 19/20] Read the creating frame with inspect.currentframe() creation_site() read its caller's frame with sys._getframe(1), which needed a lint suppression. inspect.currentframe() and f_back reach the same frame through the public API (PER-16340). Co-Authored-By: Claude Opus 5.5 --- permit/utils/sync.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/permit/utils/sync.py b/permit/utils/sync.py index e27320ac..2aaf7974 100644 --- a/permit/utils/sync.py +++ b/permit/utils/sync.py @@ -101,7 +101,8 @@ def creation_site(instance: object) -> _CallSite: for cls in type(instance).__mro__ if isinstance(init := vars(cls).get("__init__"), FunctionType) } - frame: FrameType | None = sys._getframe(1) # noqa: SLF001 - see run_coroutine_sync + here = inspect.currentframe() + frame = here.f_back if here is not None else None while frame is not None and frame.f_code in inits: frame = frame.f_back return _CallSite.from_frame(frame) From cf5d0505ee72b7c663cf103b7f7e3965e7e83e61 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 18:31:17 +0300 Subject: [PATCH 20/20] Say what a facts sync timeout of 0 does under each policy With X-Wait-Timeout: 0 the PDP's wait times out at once, so the policy decides the answer: "ignore" returns the write's response and "fail" answers 424 for every write that waits. The docs said only that 0 makes the PDP answer without waiting. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- permit/config.py | 5 +++-- permit/permit.py | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c4ac9656..0794e613 100644 --- a/README.md +++ b/README.md @@ -232,10 +232,11 @@ method on `permit.api`, such as `permit.api.sync_user()`, waits when the method names does. - How long the PDP waits is `facts_sync_timeout`, or the `timeout` of `wait_for_sync()` for - the client it yields, sent as the `X-Wait-Timeout` header. `0` makes the PDP answer without - waiting. With `None`, the default of `facts_sync_timeout`, the SDK sends no header, and the - PDP waits its own default: 10 seconds, unless its `PDP_LOCAL_FACTS_WAIT_TIMEOUT` sets - another. + the client it yields, sent as the `X-Wait-Timeout` header. With `0` the time is up at once, + so the PDP does not wait, and the policy below decides the answer: with `"fail"`, every + write that waits answers 424. With `None`, the default of `facts_sync_timeout`, the SDK + sends no header, and the PDP waits its own default: 10 seconds, unless its + `PDP_LOCAL_FACTS_WAIT_TIMEOUT` sets another. - `facts_sync_timeout_policy`, or the `policy` of `wait_for_sync()`, says what the PDP does when the time is up first: `"ignore"` answers with the write's own response, and `"fail"` answers 424, which the SDK raises as a `PermitApiError`. The write is done either way. diff --git a/permit/config.py b/permit/config.py index ecfc76ab..861ac791 100644 --- a/permit/config.py +++ b/permit/config.py @@ -128,8 +128,9 @@ class PermitConfig(BaseModel): facts_sync_timeout: float | None = Field( default=None, description="With proxy_facts_via_pdp on, how many seconds the PDP waits for a facts " - "write to reach its own data before it answers, sent as the X-Wait-Timeout header. 0 " - "makes it answer without waiting. None sends no header, so the PDP waits its own " + "write to reach its own data before it answers, sent as the X-Wait-Timeout header. With " + "0 the time is up at once, so the PDP does not wait and facts_sync_timeout_policy decides " + "the answer. None sends no header, so the PDP waits its own " "default: 10 seconds, unless the PDP's PDP_LOCAL_FACTS_WAIT_TIMEOUT sets another. The " "PDP waits on the writes of users.create(), users.update(), users.sync(), " "users.assign_role(), users.unassign_role(), tenants.create(), " diff --git a/permit/permit.py b/permit/permit.py index 92acc23c..4683859c 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -163,8 +163,9 @@ def wait_for_sync( ``tenants.create_user()`` goes to the Permit REST API, so it does not wait either. Args: - timeout: How many seconds the PDP waits for the change before it answers. 0 makes - it answer without waiting. + timeout: How many seconds the PDP waits for the change before it answers. With 0 + the time is up at once, so the PDP does not wait and `policy` decides the + answer: "fail" makes every such write answer 424. policy: What the PDP does when the timeout passes first: "ignore" answers with the write's own response, and "fail" answers 424, which the SDK raises as a ``PermitApiError``; the write is done either way. None keeps the