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/.github/workflows/test.yml b/.github/workflows/test.yml index 35d01565..27b90a44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -265,10 +265,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 1ca21a1e..90c77b58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,19 +162,22 @@ 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: - `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`. @@ -362,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: diff --git a/README.md b/README.md index f7def99d..0794e613 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,61 @@ 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, 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) +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. 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. +- 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/_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/base.py b/permit/api/base.py index cb34c96e..b968503a 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 @@ -328,7 +366,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) @@ -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/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/config.py b/permit/config.py index 490c6576..861ac791 100644 --- a/permit/config.py +++ b/permit/config.py @@ -111,12 +111,33 @@ 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, 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.", ) 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. 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(), " + "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/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 32c5bf71..072a13da 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 @@ -580,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``, @@ -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/exceptions.py b/permit/exceptions.py index 319b1dac..070188ef 100644 --- a/permit/exceptions.py +++ b/permit/exceptions.py @@ -77,18 +77,33 @@ 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": }``. 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. + """ 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/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..4683859c 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() @@ -128,18 +142,35 @@ 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. 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 + ``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 @@ -196,6 +227,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 +390,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..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") @@ -152,6 +156,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 +319,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/permit/utils/cloud_pdp.py b/permit/utils/cloud_pdp.py new file mode 100644 index 00000000..282af5cf --- /dev/null +++ b/permit/utils/cloud_pdp.py @@ -0,0 +1,111 @@ +"""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 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, 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". + + 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`` + 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() + + +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}" + ) + + +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.\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/permit/utils/sync.py b/permit/utils/sync.py index 3d80aa58..2aaf7974 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,31 @@ 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) + } + 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) + + _blocking_call_site: ContextVar[_CallSite | None] = ContextVar( "permit_blocking_call_site", default=None ) 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/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_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 diff --git a/tests/test_container_pdp_only_offline.py b/tests/test_container_pdp_only_offline.py new file mode 100644 index 00000000..bf72aadb --- /dev/null +++ b/tests/test_container_pdp_only_offline.py @@ -0,0 +1,610 @@ +"""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, 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. 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 +pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. +""" + +import asyncio +import inspect +import socket +import sys +import warnings +from collections.abc import Iterator +from operator import attrgetter +from typing import Any, Literal + +import aiohttp +import pytest +from pytest_httpserver import HTTPServer +from werkzeug import Request + +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.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" +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: + """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 + + +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.""" + # 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( + 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 +) -> 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}" + ) + + +# --- facts through the PDP ------------------------------------------------------------ + + +# 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", 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: Case, + 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] == [case.request] + 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 'acme' was not found.", +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + "case", + [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( + *, + httpserver: HTTPServer, + pdp_server: HTTPServer, + split_config: PermitConfig, + case: Case, + 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 '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 + assert len(pdp_server.log) == 1 + assert httpserver.log == [] + + +# 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, "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, method=method).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) +@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 +) -> 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, 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": NEW_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": list(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 + + +# --- docstrings ----------------------------------------------------------------------- + + +# 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 " + "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()) + + +@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) + + +# --- the warning at creation ---------------------------------------------------------- + + +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.\n" + f"{USE_A_CONTAINER_PDP_FOR_FACTS}\n" + f"Read more about setting up the PDP at {DOCS_LINK}" + ) + + +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( + "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( + 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, 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] == [CASES["users.create"].request] diff --git a/tests/test_facts_sync_offline.py b/tests/test_facts_sync_offline.py new file mode 100644 index 00000000..91f88e10 --- /dev/null +++ b/tests/test_facts_sync_offline.py @@ -0,0 +1,363 @@ +"""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). 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 collections.abc import Callable +from contextlib import nullcontext +from operator import attrgetter +from pathlib import Path +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.facts_methods import ALIASES, CASES, FACTS_APIS, USER, Case, on_pdp +from tests.utils import FACTS, Call, call, offline_config, sent + +FLAVOURS = ["async", "sync"] + +HEADERS = ("Authorization", "Content-Type", "X-Wait-Timeout", "X-Timeout-Policy") + +REPO_ROOT = Path(__file__).resolve().parents[1] +PDP_SPEC = REPO_ROOT / ".github" / "api-specs" / "pdp.json" + +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 == [] + + +# --- 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", + } +) + + +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", + ) + server, other = (pdp_server, httpserver) if on_pdp(case) else (httpserver, pdp_server) + handler = server.expect_request(case.path, method=case.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) == case.request + 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 diff --git a/tests/test_rbac_e2e.py b/tests/test_rbac_e2e.py index ced13bd8..473a93a8 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], @@ -540,6 +541,7 @@ async def user_authorized() -> 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."""