Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0419111
Send X-Wait-Timeout when the facts sync timeout is 0
zeevmoney Oct 2, 2026
1467831
Build the container-PDP-only 404 message in one place
zeevmoney Oct 2, 2026
79f6043
Say when a facts or pdp_api route needs the container PDP
zeevmoney Oct 2, 2026
2958dac
Say "container PDP only" in the affected methods' docstrings
zeevmoney Oct 2, 2026
a5e0d81
Pin the route and sync headers of every proxied facts method
zeevmoney Oct 2, 2026
31aefae
Document which proxied facts writes the PDP waits on
zeevmoney Oct 2, 2026
9a15830
Warn when proxy_facts_via_pdp points at the cloud PDP
zeevmoney Oct 2, 2026
0073941
Skip container-PDP-only e2e tests on the cloud PDP
zeevmoney Oct 2, 2026
ec8d7fe
Check the cloud PDP's 404s for pdp_api and proxied facts end to end
zeevmoney Oct 2, 2026
0da348a
Fail fast if the cloud PDP test fixture cannot resolve locally
zeevmoney Oct 2, 2026
294b905
Merge the container-PDP-only changes (PER-16340)
zeevmoney Oct 2, 2026
cdd34af
Merge the facts sync changes (PER-16339, PER-16681)
zeevmoney Oct 2, 2026
e7bff65
Check the cloud PDP's 404 for every proxied facts method
zeevmoney Oct 2, 2026
2419462
Say what the facts proxy does on the cloud PDP in its docs
zeevmoney Oct 2, 2026
d374ee9
Point contributors at the table of proxied facts requests
zeevmoney Oct 2, 2026
840fd6d
Count only a 404 with no body as the cloud PDP's
zeevmoney Oct 2, 2026
04816d9
Say why any 404 at the cloud PDP's address counts as its own
zeevmoney Oct 2, 2026
25cb8a6
Test which pdp addresses count as the cloud PDP's
zeevmoney Oct 2, 2026
59a6824
Document the details of the cloud PDP's 404 on PermitApiError
zeevmoney Oct 2, 2026
e973d0f
Give the cloud PDP warning the error's advice and docs link
zeevmoney Oct 2, 2026
d76e3ac
Read the creating frame with inspect.currentframe()
zeevmoney Oct 2, 2026
b54c2e0
Merge the base branch's RBAC e2e polling fix
zeevmoney Oct 2, 2026
cf5d050
Say what a facts sync timeout of 0 does under each policy
zeevmoney Oct 2, 2026
11b6354
Merge the lifecycle test fix from the base branch
zeevmoney Oct 2, 2026
8fc97cf
Merge the review fixes from the base branch
zeevmoney Oct 2, 2026
47bc889
Merge the base branch's backported e2e and schema fixes
zeevmoney Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 114 additions & 40 deletions .github/scripts/api_coverage_allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 15 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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:

Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<YOUR_API_KEY>", 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
Expand Down
Loading