Conversation
The resolved dependency tree was clean, but the published `>=` floors let a consumer install versions carrying 34 known advisories. Because this package ships open ranges with no lockfile, the floor is the real exposure -- so the scan covers both the current resolution and the lowest versions the specs permit. Dependency fixes: - aiohttp >=3.14.3 (clears 32 advisories, incl. CVE-2026-69244, an out-of-bounds heap read in the HTTP response parser this client exercises on every call) - pydantic >=1.10.13 (CVE-2024-3772, EmailStr ReDoS; the SDK uses EmailStr) - werkzeug >=3.1.6, pytest >=9.0.3 - drop httpx: never imported, and the only path by which h11 (CVE-2025-43859, CRITICAL) and anyio entered the tree - drop zipp and aioresponses: both unused, and aioresponses 0.7.9 is incompatible with aiohttp 3.14.3 - python_requires >=3.10; the declared >=3.8 was already unachievable Gates: - Trivy over three trees (runtime ceiling, runtime floor, dev), sticky PR comment, blocking on fixable HIGH/CRITICAL only - release split into build -> scan -> publish, so publish is unreachable unless the scan passed - weekly cron posting the findings themselves to Slack, not just a verdict - Dependabot with cooldowns and versioning-strategy: increase - delete release.yml, which raced python-sdk-publish.yml on every release - existing workflows hardened: 48 zizmor findings (12 high) to zero Also fixes 10 minor SDK bugs with 33 offline regression tests. Nine major correctness bugs found along the way are tracked in PER-16174 rather than changed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…endent pytest_httpserver's `httpserver` fixture is session-scoped: the first test that requests it binds the one shared server for the entire run. The address override lived in test_rbac_e2e.py, so it only applied when that module happened to touch the fixture first. Adding tests/test_offline_regressions.py broke that assumption -- it sorts earlier, claimed the session server on a random port, and test_api_timeout and test_pdp_timeout then failed against their hardcoded localhost:9999 with "Cannot connect to host". Moving the fixture to conftest.py makes the address apply session-wide and removes the latent ordering dependency, which any future test using httpserver would otherwise have tripped over too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ations" This reverts commit 160f129.
The one remaining red check is pre-existing — here is the proof
Experiment: pushed commit Result — with the SDK code identical to main, it still fails identically: Corroborated independently: the same test also fails against a local Permit backend + PDP stack built during this work — a completely separate control plane from the shared CI project. Why it fails. The assertion at line 227 expects a role assignment to survive the deletion of the user who owns it: await permit.api.users.bulk_delete([user.key for user in CREATED_USERS])
assignments = await permit.api.role_assignments.list()
assert len(assignments) == len_assignments_original + 1 # (tenant role)The surviving Worth fixing separately — either the test's assumption or the cascade behaviour. Not folded into this PR, which is already larger than it should be. A useful side effect of the same experimentWith So the tests genuinely catch the bugs they target rather than passing vacuously. Two regressions I did introduce, and fixedAdding |
get, get_by_key, update and delete all interpolate their argument straight into the path, and the backend validates it with validate_resource_instance_ident(instance_id, allow_uuids=True) -- a bare instance key is rejected with a 422, not accepted. The docstrings said "the key of the resource instance", which sends callers straight into that error. Wording matches what bulk_delete already documented correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps to 3.0.0 and fixes the nine major bugs tracked in PER-16174, so the
eight permanently-xfail tests can assert for real.
Sync client (permit/utils/sync.py, permit/sync.py):
- SyncClass is now idempotent. It was inherited, so a subclass re-wrapped
methods its base had already converted, giving async_to_sync(async_to_sync(f));
all 21 deprecated-facade methods raised "a coroutine was expected" before
issuing a request.
- Coroutine detection uses inspect.iscoroutinefunction and unwraps
functools/validate_arguments wrappers, instead of assuming every object whose
class is named "function" is async.
- permit.sync.Permit now overrides authorized_users, get_user_permissions and
filter_objects, which were inherited as `async def` over a synchronous
enforcer and returned un-awaitable coroutines.
Enforcement (permit/enforcement/):
- parse_obj_as is imported through the pydantic v1/v2 guard the rest of the
package uses; authorized_users() could not return at all under pydantic v2.
- bulk_check honours a per-check context and filter_objects forwards the
caller's context. It was silently dropped, so context-dependent ABAC
evaluated against {} and could return the wrong subset.
- UserInput accepts snake_case as well as the camelCase aliases; first_name
and last_name were silently discarded from every check.
Serialization (permit/api/base.py):
- dict and list bodies go through the encoder, so nested datetime/UUID/Enum
no longer dies inside aiohttp.
- exclude_none is dropped, so an explicitly-set None is transmitted as null
and an update can clear a field. exclude_unset still omits untouched fields.
Facts proxy (permit/api/tenants.py):
- tenants bulk operations addressed the PDP's users endpoint.
tests/endpoints/test_bulk_operations.py asserted that a tenant role assignment
outlives the user who owns it; deleting the user removes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The un-xfailed tests all run against one shared environment and were fighting each other: fixed keys (admin, viewer on the built-in __tenant resource), a shared resource urn, assertions on global object counts, and teardown that called pytest.fail on a 404 so "already deleted by another test" turned a passing test red. Several also leaked every object they created. Each test now derives its keys from tests/utils.unique_key, asserts against its own objects rather than environment-wide counts, tears down in a finally via handle_cleanup_error, and polls with a bounded retry where it waits for a fact to reach the PDP. Verified by running twice in a row against a deliberately dirty local environment. test.yml starts the PDP as a step rather than a service container. A service container is created before the first step runs, so it could only be given the long-lived PROJECT_API_KEY while the tests authenticate with the per-run scratch environment key. The PDP rejected every decision with a 403, which is why the ReBAC and RBAC decision tests could never pass. That 403 also surfaced as "cannot connect to the PDP container": the enforcer read error bodies with response.json(), and the PDP sends auth rejections as plain text, so ContentTypeError -- an aiohttp.ClientError -- was caught by the connectivity handler and the real status was lost. Error bodies are now read without assuming JSON, and the message names the status and body. tests/test_abac_pdp.py's three cloud-PDP tests now skip with a reason instead of failing: as CI is configured they never reach the cloud PDP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PDP reports 503 on /healthy until its horizon component finishes pulling config and a policy bundle. Waiting for it immediately after docker run made that bootstrap serial with the job; one leg was ready in 29s and the other still was not at 60s. The wait now happens after dependency installation, so the bootstrap overlaps with it, with a 180s ceiling. Changing an ABAC condition set makes the policy generator recompile the environment's rego and redistribute the bundle, which is much slower than the fact sync RBAC uses. test_abac_e2e timed out at 90s against the real cloud PDP; raised to 300s. The poll returns as soon as the rule lands, so a healthy run is no slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup.py used a bare find_packages(), which ships a TOP-LEVEL `tests` package into every consumer's site-packages where it shadows their own `tests` module. Verified against the published permit==2.8.3, which does exactly that. Now excluded, along with `harness`. permit.pdp_api never passed a timeout to its HTTP client, so the documented pdp_timeout was silently ignored on every permit.pdp_api.* call while the enforcer honoured it. It also duplicated ClientConfig and pagination_params verbatim from permit.api.base; it imports them now. Removed, none of which had a single caller in permit/, tests/ or harness/: set_if_not_none (enforcer), OpaResult and the JWT alias (interfaces), ApiKeyLevel (a self-declared deprecated alias of ApiKeyAccessLevel), LoginAsErrorMessages (never compared against or returned), and three unused TypeVars in the PDP base module. _model_dump was defined identically in both arms of the pydantic version split; hoisted to one definition. Its `mode` parameter stays and stays ignored on purpose -- it absorbs a v2-style argument that pydantic v1's .dict() would reject. Repo cruft: .isort.cfg (isort is not run; ruff's I rules are), uv.lock (a three-line stub declaring requires-python >=3.14, contradicting setup.py), the Makefile publish target (a second release path that bypasses the gated build -> scan -> publish workflow) and a .DEFAULT_GOAL pointing at a help target that did not exist. .gitignore's .DS_Store rule was inert because of an inline comment. Dependencies: dropped pytest-mock (no test uses it) and pytest-cov (coverage is never requested, including in CI). Corrected the werkzeug comment -- it is now a direct test import, not just a pytest_httpserver transitive. Also dropped two references to .trivyignore, which audit-deps.sh deliberately disables with --ignorefile /dev/null, so both were advertising a suppression mechanism that does not work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The condition sets and rule this test creates never reach the PDP's policy bundle, so the decision it waits for never becomes true. The PDP says so in the debug.abac payload the SDK already logs: ~90s of no_matching_usersets with "known usersets: ['rules']" (the empty-package placeholder), then one bundle carrying only the condition sets autogenerated by the resource and role creates ten seconds earlier, then nothing for the remaining 300s. The data channel stayed healthy throughout. The pipeline is event-driven with no polling fallback (the default scope is created with poll_updates=False and batching drains rather than waits), so this is a stall, not slowness, and no timeout makes it pass. Skipped rather than xfailed so it reports honestly instead of looking like coverage. Only the three decision assertions are skipped. Everything above them still runs against the real control plane -- condition set and rule create, type round-trip, paginated list, filtered list, permission-format assertion -- and so does the teardown, because pytest.Skipped derives from BaseException and escapes the test's except Exception. Ruled out as causes: resource_id passed as .hex (the generator keys on the resource key, never the id), inline check attributes (they win the object.union_n in the generated rego and the PDP echoed them back), and a missing setup step. No other test is exposed: condition_set_changes.py is the only policy synchronizer handler that generates rego, so RBAC and ReBAC decisions resolve against data.* on the fact channel, and this is the only test that touches condition sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
resource_relations.list() declared List[RelationRead], but the route is declared response_model=PaginatedResult[RelationRead], so against current backend main the call raised "ValidationError: value is not a valid list" -- the method was unusable. It now returns PaginatedResultRelationRead; callers read .data. BREAKING, and in the 3.0.0 notes. (That change was written earlier and swept into the previous commit by a bare `git add -A`; this records what it actually is.) Two docstrings corrected against the backend, both of which sent callers into a confusing error: - resource_roles.assign_permissions/remove_permissions said permissions are <resourceKey:actionKey>. A resource role is scoped to its own resource, so each entry is a BARE action key. Passing the qualified form makes the server read the whole string as an action key and reject it with a 404 naming '<resource>:<resource>:<action>' -- a doubled prefix that reads like the SDK concatenated wrongly, when it is the server quoting what it was given. - role_assignments.list(resource_instance_key=...) takes a `resource_type:instance_key` ident or an instance uuid, never a bare key. Regression tests pin the exact wire strings on both pydantic majors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Every remaining CI failure was one cause: HTTP 429 on a cleanup call. Enabling the eight previously-xfail tests and giving each its own objects made the suite create and tear down far more than before, and teardown is where the burst lands -- one leg reported 3 failed and 2 teardown errors, the other 7 failed, all of them 429 on a delete. handle_cleanup_error now tolerates 429 alongside 404, for the same reason 404 is tolerated: neither leaves the test's assertions in doubt. A throttled delete leaks an object, and CI deletes the whole scratch environment afterwards, so it is reclaimed. Any other status still fails the test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The previous commit tolerated 429 during teardown. That was wrong in a way the next CI run made obvious: a tolerated DELETE leaves the object alive, so the assert-it-is-gone check that follows failed with "DID NOT RAISE PermitApiError". The tolerance manufactured a worse failure than the one it hid. 429 is no longer tolerated. It was also the wrong layer. The run after showed 429 arriving in test BODIES as well -- test_rebac_e2e, test_sync_client and test_user_invites_complete_e2e all failed mid-test -- so cleanup was never the whole problem. The suite runs against one environment on a shared cloud project and now creates and tears down considerably more than it used to, which exceeds the burst limit. The eight tests that were xfail until this branch had been swallowing these 429s all along. conftest wraps the SDK's five HTTP verbs for the test session only, retrying a 429 with exponential backoff so the call actually succeeds. The SDK is untouched: adding implicit retries to a published client would be a behaviour change callers did not ask for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Six attempts (~63s of backoff) still ran out on one teardown, leaving CI at 1 failed / 102 passed. Raised to nine, which caps a single call at roughly two minutes of waiting and exits the moment it succeeds. Also honours the server's Retry-After when it sends one, and adds jitter to the exponential fallback so concurrent callers do not retry in lockstep and re-trip the limit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
bulk_check() reads each query's context with .get(), so a query without
one is valid at run time, but the TypedDict declared the key as required
and mypy rejected every bulk_check([{"user", "action", "resource"}]) call.
TypedDict comes from typing_extensions so NotRequired is honoured on 3.10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The REST API client, the PDP API client and the enforcer sent "bearer <token>". The scheme is case-insensitive per RFC 7235, but "Bearer" is the canonical form every other Permit SDK sends, and at least one server once rejected the lowercase form with a 401. A facade-level offline test now reads the header each client actually puts on the wire. Co-authored-by: Suren <suren@cercli.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
On Python 3.14, pydantic 1.x before 1.10.25 and 2.x before 2.13 crash on
import permit ("unable to infer type for attribute"), so the pydantic
requirement is split by Python version and excludes those releases there.
pydantic 2.0 is excluded everywhere: its pydantic.v1.parse_obj_as rejects
the SDK's __root__ models, failing every parsed API response.
The typing-extensions and loguru floors could not import on current
Pythons (typing-extensions before 4.6 breaks on 3.12+, before 4.12 on
3.13+, 4.12-4.13 lose TypedDict keys on 3.14; loguru before 0.7.3 warns on
3.14), so they rise to 4.14.0 and 0.7.3. deprecation.py uses
inspect.iscoroutinefunction instead of the asyncio one 3.16 removes, and
the pydantic version parser accepts pre-releases such as 2.14.0b2, which
crashed the import.
A new compatibility CI job runs the offline suite on Python 3.10-3.14 at
both the lowest allowed and the newest dependency versions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
permit now declares itself typed, and type checkers see what actually runs: the SDK models are typed as the pydantic.v1 models they are on both pydantic majors (TYPE_CHECKING import branches, pydantic.v1.mypy plugin), generated model defaults are keyword arguments so optional fields no longer read as required, API methods that accept dicts at runtime accept them in their annotations (typing-only ModelInput/ModelListInput, runtime validation unchanged), and the sync client is typed as synchronous through a generated stub (permit/_sync_types.pyi, with a drift test). The pre-3.14 pydantic floor rises to 1.10.18: 1.10.17 is the first release with the pydantic.v1 package, and 1.10.13-1.10.17 emit about 2,400 DeprecationWarnings on Python 3.13. A consumer fixture is type-checked with mypy --strict in the test suite on every CI leg, and the release and compatibility builds assert the wheel ships py.typed and the stub. Runtime behaviour is unchanged: a snapshot of every public name, signature, validate_arguments model and model field matches the previous commit on both pydantic majors. Co-authored-by: Tarcio Silva <luan.coc13@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Under pydantic 2, permit validates emails with the pydantic.v1 copy that pydantic bundles. That copy is fixed for CVE-2024-3772 (ReDoS in email validation) only from pydantic 2.4.2, which bundles 1.10.13: 2.0.1 bundles 1.10.11, and 2.4.0 and 2.4.1 bundle 1.10.12. Below Python 3.14 the spec still allowed 2.0.1-2.4.1. The pre-3.14 requirement is now two lines. Python 3.10-3.12 allow pydantic 2 from 2.4.2. Python 3.13 allows it from 2.8.0, because 2.4.2-2.7.x pin a pydantic-core with no Python 3.13 wheels. The pydantic 1 floor (1.10.18) and the 3.14 line are unchanged. Nothing resolved the pydantic 2 floor before: lowest-direct over requirements.txt picks pydantic 1, so the floor CI legs and the audit's runtime-floor tree only ever saw 1.10.18, and Trivy treats 2.4.0 as fixed. A pydantic-v2-floor compatibility leg on every Python and a runtime-floor-pydantic-v2 audit tree now resolve lowest-direct with pydantic held to >=2, and every format_audit.py call reads the new tree. Every setup-uv step pins uv 0.12.18, so a uv release cannot change which floor is tested or scanned. The offline tests check, per Python, that no allowed pydantic is affected by the CVE and that each major is allowed from its floor up. Part of PER-16176. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The comment claimed py.typed and _sync_types.pyi ship only because package_data lists them. setuptools 69 and later include them by default; 68.2.2 does not. The project has no [build-system] table, so a build can still run with an older setuptools, which is what package_data guards against. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The docstrings in tests/test_fix_permissions.py and tests/test_fix_relations.py now state what the API does: how it reads a role's permission strings, which resource_instance filter values it rejects, and the paginated envelope the relations list returns. They no longer point at server source files. The Dependabot cooldown comment no longer names a policy kept outside this repository. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
PYDANTIC_CANDIDATES is now built by explicit loops instead of a triple-nested comprehension. The list is unchanged (931 entries). audit-deps.sh no longer runs mkdir -p on the output directory before writing the pydantic constraint file: compile_tree has already created it at that point. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Every job carried a notice that ubuntu-latest moves to Ubuntu 26 from October 19, 2026. Pinning ubuntu-24.04 keeps the image these workflows run on today (ubuntu-latest resolves to ubuntu-24.04 now), so the move becomes a deliberate change here rather than one that arrives unannounced under an unchanged workflow. No step changes: the tools they call (shellcheck, docker, jq, curl) are the ones the current image has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The Comment on PR step ran only when hashFiles('/tmp/audit/comment.md')
was non-empty. hashFiles ignores every file outside the workspace, so the
guard was always false and no audit comment ever reached a PR, including
the pip-audit gap notice that is meant to appear there.
The step now runs whenever the artifact downloads, and the script checks
the report itself. A report that is missing or does not start with the
marker means the render step did not finish: the step warns and posts
nothing. A report over GitHub's 65,536-character comment limit would be
rejected, so the comment then links to the job summary instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
audit-deps.sh gave pip-audit a temporary --cache-dir, saying it kept pip-audit away from the runner's pip HTTP cache. pip-audit 2.10.1 never uses pip's cache for its vulnerability lookups: its services build their session with use_pip=False, so without --cache-dir it already uses its own directory, which is empty on a fresh runner. The mktemp, the flag and the cleanup did nothing and the comment explaining them was wrong. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The Slack line joined the gap labels as they were, so it read "pip-audit did not fully check pip-audit:dev-ceiling, pip-audit:runtime-floor". It now drops the scanner prefix and names the trees alone. The test covers two trees, one of them with two gaps, and checks the whole line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The Install Trivy step runs trivy-action only to install Trivy, and the action always scans scan-ref. The repo root has nothing Trivy can scan, so every Dependency Audit and Security Gate run logged "WARN [report] Supported files for scanner(s) not found". hide-progress sets TRIVY_QUIET, which drops that warning along with the INFO lines and the DB progress bar. Fatal errors still print (checked with Trivy 0.70.0, the version the action installs). The step comment also said the step warms Trivy's vulnerability DB for the real scan. It does not: the action sets TRIVY_CACHE_DIR only inside its own step, so audit-deps.sh uses Trivy's default cache and downloads its own DB. The comment now says only what the step does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The trigger comment said Dependency Audit was only meant to become a required check and that a red audit did not block a merge. Branch protection on main already requires Dependency Audit, Audit Script Tests and Workflow Hardening, so the comment now says that. It also dropped the "warm Trivy DB" timing, since the audit's scans download their own DB. The Slack guard's comment said the repository had no SLACK_WEBHOOK_URL. The secret is set and the weekly run posts, so the comment now says what the guard is for: a repository or fork without the secret gets a warning rather than a failed job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Dependency Security AuditScanned: requirements.txt + requirements-dev.txt, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major) ✅ No known vulnerabilities found. Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL. |
The package metadata named one person as the author. It now names Permit.io with the public support address, so PyPI shows the company that maintains the SDK. The author field is informational only: publishing is unaffected. The e2e tests used the same person's name and email as sample user data. They now use a fictional user; no assertion depends on the values. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Five offline test files each built their own PermitConfig for the local mock server, in two shapes, and two of them repeated the same `config` fixture, the Call/call table helpers, the sent() request capture and the test project's paths. A change to how the offline tests configure the SDK had to be made five times. offline_config(), Call, call(), sent() and the FACTS/SCHEMA paths now live in tests/utils.py, next to the existing shared helpers, and the `config` fixture lives in conftest.py. The same 276 tests are collected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The deprecated flat methods on permit.api warn from inside their coroutine with stacklevel=2. The async client awaits that coroutine from the caller's code, so its warning names the caller's line. The blocking client runs the coroutine under asyncio.run, sometimes in a worker thread, so the warning named asyncio/events.py instead, and Python's default filters, which show a DeprecationWarning only when it points at __main__, hid it from scripts. async_to_sync now records the line that called the blocking method and passes it to the thread that runs the coroutine, which holds it in a context variable while the coroutine runs. deprecated() warns at that line when it is set, through warnings.warn_explicit with the caller module's name and registry, the values warnings.warn itself uses. The context variable replaces the flag that marked a coroutine as driven by a blocking call, so re-entrant calls behave as before. With no Python caller frame, as for an atexit hook, the warning names <sys> line 0, as warnings.warn does. The async client's warning is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
permit/__init__.py imported PYDANTIC_VERSION under its own name to decide whether to warn about pydantic 1, and `from permit.api.models import *` exported it as well, because models.py imported it the same way and defines no __all__. So permit.PYDANTIC_VERSION showed in dir(permit) and `from permit import *` handed it to callers, although it is an internal constant. encoders.py and pdp_api/role_assignments.py read it from there. Both modules now import it as _PYDANTIC_VERSION, the way `import warnings as _warnings` already is, and the two internal readers import it from permit.utils.pydantic_version. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The blocking client's call-site warning passed the calling module's
globals to warnings.warn_explicit. From Python 3.12, warn_explicit then
asks the module's loader for the source line. A script's __main__ has a
loader but no __spec__, so each call there issued a second
DeprecationWarning ("Module globals is missing a __spec__.loader"), and
under -W error that one was raised instead of the deprecation. Code run
by exec() or runpy.run_path() has neither, so the call raised
ValueError.
warnings.warn does not pass module globals, so the call-site warning no
longer does either, and the two now issue the same single warning.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Pointing the blocking client's warnings at the caller made call_site a required second argument of run_coroutine_sync, which has taken just the coroutine since 2.x, and added CallSite and blocking_call_site() as public names of permit.utils.sync. None of them was meant as new API. async_to_sync now hands its call site to a private _run_blocking, and run_coroutine_sync(coroutine) keeps its signature: it records the line that called it, so a direct caller still gets the re-entrant path and warnings attributed to that line. The call-site class and context variable are private, and deprecated() reads the context variable directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The blocking client's warning passes the calling module's warning registry to warn_explicit, which is what makes Python's default filters print it once per line, as they do for the async client. No test covered that: the __main__ script called each client once, and replacing the registry with None or a fresh dict passed the whole suite. The script now calls each client three times from the same line and still expects one line each. The no-caller test started a thread from C and recorded its warning with catch_warnings in the main thread. Under context-aware warnings, the default on free-threaded 3.14, that thread never reaches the recorder, and the test did not wait for the thread to finish. It now runs a script whose atexit hook calls the method, the case the code handles, and checks the script's output. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
After regenerating permit/api/models.py, the hand-written import header has to be re-applied, and the Makefile describes it. The header now imports PYDANTIC_VERSION as _PYDANTIC_VERSION, so that permit/__init__.py's star import of the models does not export it; say so where someone regenerating the models reads the steps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Open issues and PRs this release resolvesClosed automatically when this PR merges (by
Superseded community PRs. Pull requests are not closed by keywords, so these will be closed by hand after 3.0.0 is released:
Replies on the issues and PRs themselves will follow the release. |
Some SDK behaviours had no offline test, so a regression in them would only show up against a live API or PDP. These tests pin them with a local pytest_httpserver or a static scan: - request bodies keep every key and every value's JSON type, nulls included, and are identical under both pydantic majors - users.update sends a field set to None as null - no SDK module imports the top-level pydantic namespace outside its pydantic 1 branch - get_user_permissions unwraps both PDP response shapes - projects.create with an environment key is refused before any request - delete_tenant_user, environments.copy and user_invites.get send the request the API schema documents, and an unknown invite raises a 404 PermitApiError Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Nothing regenerates permit/api/models.py on a schedule, so the public API schema can change without the SDK noticing. A model that still requires a field the API stopped sending, or an enum that lacks a value the API now returns, fails only when a user parses such a response. check_schema_drift.py generates models from the live schema with the pinned generator and the Makefile's flags, and compares them with models.py through the AST: classes, fields, types, required or optional, defaults, aliases, Config.extra and enum members. Changes that make the SDK send what the API rejects, or reject what it returns, fail. A class or optional field the SDK lacks is only reported. Today's differences are allowlisted with a reason each, so only new drift is flagged. The Schema Drift workflow runs it weekly, on dispatch and on PRs that touch these paths. It is not a required check, and a scheduled run that finds drift or cannot run posts counts to Slack. The Audit Script Tests job runs its unit tests. The Makefile now pins the generator that built models.py, and its comment above generate-models documents the check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The API schema documents a relationship tuple's object_id as optional (null means every resource of the object's type) and the detailed tuple's subject, relation, object and tenant detail blocks as optional, and lists nats_pdp_config as an API key owner type. models.py required the first two and lacked the third, so relationship_tuples.list() and create() raised ValidationError on a wildcard tuple, and environments.get_api_key() on a NATS PDP key. APIKeyOwnerType, RelationshipTupleRead and RelationshipTupleDetailedRead now match what the pinned generator emits for them from the current schema. The 13 schema drift allowlist entries that recorded these differences are removed, and the live check passes without them. object_id and the four detail attributes are now Optional, so code that reads them may need a None check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The offline tests pinned only the bulk PDP routes, so a single-object write sent to the wrong one went unnoticed: pointing tenants, relationship_tuples and role_assignments at /facts/users still passed the whole suite. users.create, tenants.create, resource_instances.create, relationship_tuples.create, role_assignments.assign and users.assign_role now each assert the method, path and body they send with proxy_facts_via_pdp on. The request-body test now builds each model inside the test, so a model that fails to build fails its own case instead of the whole module, and covers ResourceCreate with action and attribute blocks and RelationshipTupleCreate. A new test checks that users.get() keeps each attribute's JSON type (bool, int, whole float, null) under both pydantic majors. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Only DriftError mapped to exit 2. Any other exception, such as a models file that is not UTF-8 or a download cut short (http.client.IncompleteRead), ended the script with Python's exit 1, which the workflow reads as drift with 0 differences listed. main() now also catches any other exception, prints its traceback to stderr, writes the did-not-run report and returns 2. A failed schema download, including a truncated one, is now retried twice, 5s and then 10s later, before the check gives up with exit 2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Neither job had a timeout-minutes, so a hung step could hold a runner for GitHub's six-hour default. The drift job now stops after 20 minutes, which covers the script's three 60s download attempts and 10-minute generator limit, and the notify job after 5. The notify job now also runs on workflow_dispatch, as the Security workflow's does, so the Slack path can be tried on demand. A manual run posts whatever the result, so the message now has a passed variant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
--exclude-newer 2025-09-18 is a bare date, which uv reads in the local time zone, so the cutoff moved with the machine running it. The Makefile and the drift script now pass 2025-09-18T00:00:00Z, which still resolves datamodel-code-generator 0.33.0 and the same dependencies. The comment above generate-models said an unchanged spec regenerates an unchanged file; the timestamp header changes and some lines in models.py are wider than the generator wraps them, so it now says the same models. It also names the files whose changes trigger the pull request run, says that exit 0 means no new failing drift and no stale entry, and says why deleting a class from models.py is only reported. The drift report now points at the comment above generate-models in the Makefile instead of "the comment above it". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Linear issues
py.typedauthorized_users()under pydantic 2py.typedcontribution, shipped here with the typing fixespdp_config_idGitHub issues
Closes #116
Closes #122
Closes #124
Supersedes the community PRs #123 and #125. Their changes are included here, and each author is credited with a
Co-authored-bytrailer.Why
This started as a CVE fix and became the 3.0.0 release.
CVEs.
permitio/permit-pythonhad no dependency scanning and no gate on PRs or releases. The resolved dependency tree was clean. All the exposure was in the floors: the package publishes open>=ranges with no lockfile, soaiohttp>=3.12.14legitimately resolves to 3.12.14 and a consumer inherits every CVE fixed since. That was 34 advisories acrossaiohttp,h11,anyioandpydantic. A scanner pointed at what CI installs reports all green.Correctness. Nine major bugs turned up along the way (PER-16174). Among them: context-dependent ABAC checks evaluated against an empty context,
authorized_users()could not return under pydantic v2, and the sync client's deprecated facade raised before issuing any request. They survived because 8 of the e2e tests had been@pytest.mark.xfailfor about two years. Since the Python floor already had to move (below), this ships as a major version that fixes them properly.Open backlog. Every open issue and community PR was triaged against this release. The real, still-present ones are fixed here:
import permitcrashed on 3.14 with the pydantic versions the old ranges allowed.Authorization: bearer: the SDK sent a lowercase scheme; it now sends the standardBearer.py.typedmarker a contributor proposed is shipped here together with the typing fixes that make it safe; on its own it would have produced false errors on valid code.The other open items are already fixed or superseded. They will be closed with an explanation once this ships.
Open SDK tickets. The ones that were still real are fixed here too: audit-log models that rejected logs without a
pdp_config_id, nothing guarding the sync client against drifting from the async one, and SDK surfaces with no tests (resource actions, action groups and the deprecated facade).4.0 deprecations. 3.0.0 starts warning about the two things 4.0 removes: pydantic 1 support and the flat methods on
permit.api. See Deprecations.Breaking changes
All of these need a line in the release notes.
Compatibility
python_requires>=3.10). This can't be avoided: aiohttp 3.14.3 is the only release that fixes CVE-2026-69244, and it requires 3.10. 3.8 was already unsupported in practice, since the old aiohttp floor needed 3.9. A 3.9 user who runspip install -U permitgetsRequires-Python >=3.10, and pip quietly keeps the old, vulnerable version.httpxis no longer installed transitively, and neither areh11,httpcore,anyioorzipp. The SDK never importedhttpx. Anyone who relied onpermitpulling it in must now declare it themselves.pydantic:>=1.10.18,<2or>=2.4.2on Python 3.10–3.12;>=1.10.18,<2or>=2.8.0on 3.13;>=1.10.25,<2or>=2.13on 3.14.DeprecationWarnings on 3.13; it also ships thepydantic.v1package the type hints need.pydantic.v1copy that pydantic bundles, and only 2.4.2 and later bundle one fixed for CVE-2024-3772 (1.10.13). pydantic 2.0 exactly also fails every parsed response: itspydantic.v1.parse_obj_asrejects__root__models.pydantic-corewith no Python 3.13 wheels; 2.8.0 (pydantic-core2.20.0) is the first that has them.import permit("unable to infer type for attribute").typing-extensions:>=4.14.0. Releases before 4.6 breakimport permiton 3.12+, releases before 4.12 break it on 3.13+, and 4.12–4.13 loseTypedDictkeys on 3.14.loguru:>=0.7.3. Earlier releases warn on 3.14 about an asyncio API that Python 3.16 removes.permitis now a typed package (PEP 561py.typed). Type checkers used to skippermitwithimport-untyped; now they check calls into it.ignore_missing_importsor# type: ignore[import-untyped]forpermit, but genuine type errors in their code may now surface..model_dump()on an SDK model now fail type checking; they already failed at runtime.pydantic.v1.mypyplugin; no plugin is needed.API
resource_relations.list()now returnsPaginatedResultRelationRead, so callers read.data.permit.sync.Permit.authorized_users(),get_user_permissions()andfilter_objects()are now synchronous. Callers should drop theawait.ContextStore.register_transform(),ContextStore.transform()andContextTransform. A registered transform was never applied, so these did nothing.ApiKeyLevel, a deprecated alias ofApiKeyAccessLevel.LoginAsErrorMessages,OpaResultand theJWTalias. None of them had a caller.pdp_config_idonAuditLogModelandDetailedAuditLogModelis nowOptional[UUID], andDetailedAuditLogModel.objectsis optional.Engine.GENERICandGenericEngineDecisionLogare new.object_idonRelationshipTupleReadandRelationshipTupleDetailedReadis nowOptional[UUID], andRelationshipTupleDetailedRead'ssubject_details,relation_details,object_detailsandtenant_detailsare now optional.APIKeyOwnerTypegainsnats_pdp_config.Wire behaviour (same API, different bytes). Each change was checked against the API's request definitions:
Noneis now sent asnull, so an update can clear a field. Before,exclude_nonedropped it:users.update(key, UserUpdate(email=None))sent{}and quietly did nothing. Fields you never set are still omitted.users.assign_role/unassign_roleomit unset fields, matchingrole_assignments.assign. The API treats an omitted field andnullthe same for these fields.elements.login_assends canonical hyphenated UUIDs instead of 32-character hex. The API accepts both spellings and resolves them to the same record.Authorizationheader usesBearer, notbearer. The scheme is case-insensitive (RFC 7235), so nothing breaks; it is listed because the bytes on the wire change.permit.api.assign_role()andunassign_role()forward topermit.api.users.assign_role()andunassign_role(), so they send the request those methods send (/users/{user}/roles) instead of/role_assignments. Both have the same effect.Kept on purpose:
PermitConnectionErrorstill inherits from the deprecatedPermitException. Moving it underPermitErrorwould silently stopexcept PermitExceptionfrom catching connection failures.Deprecations (removed in 4.0)
Both still work in 3.x and warn with a
DeprecationWarningthat says what to use instead:import permitwarns once: "Support for pydantic 1 is deprecated and will be removed in permit 4.0. Upgrade to pydantic 2."permit.api, such aspermit.api.get_user(). Each warns with its replacement: "permit.api.get_user() is deprecated and will be removed in permit 4.0; use permit.api.users.get() instead."Both clients issue the warning at the line that made the call, so Python shows it by default in a script (
__main__), and-W errorraises it before any request is sent. The blocking client records the calling line before it runs the coroutine, since asyncio's frames would otherwise hide the caller.The README's new "Deprecations" section lists both and explains how to show or silence the warnings. A project that runs its tests with warnings as errors on pydantic 1 fails on
import permituntil it adds the filterignore:Support for pydantic 1:DeprecationWarning.What changed
Dependency CVE fixes
aiohttp>=3.12.14,<4>=3.14.3,<4pydantic>=1.10.7>=1.10.18,<2or>=2.4.2(Python 3.10–3.12);>=1.10.18,<2or>=2.8.0(3.13);>=1.10.25,<2or>=2.13(3.14)pydantic.v11.10.13: pydantic 1.10.13+, or pydantic 2.4.2+ whose bundled v1 is fixed; the higher floors are compatibility fixes (breaking change 3). Dual v1/v2 support is kepttyping-extensions>=4.5.0,<5>=4.14.0,<5import permiton 3.12+ (breaking change 3)loguru>=0.7.0,<1>=0.7.3,<1httpx>=0.24.1,<1h11(CVE-2025-43859, CRITICAL) andanyio(CVE-2026-63374, CRITICAL) got into the treezipp>=3.19.1werkzeug(dev)>=2.3.8>=3.1.6pytest(dev)>=9.0.3aioresponses,pytest-mock,pytest-cov(dev)aioresponses0.7.9 is also incompatible with aiohttp 3.14.3Every dev dependency now has a floor. Without one, a scanner has nothing to evaluate.
Major bug fixes (PER-16174)
SyncClassnow wraps each method exactly once, however deep the inheritance goes; the facade methods had been wrapped twice, so they raised before sending anything. It detects coroutines withinspect.iscoroutinefunction, unwrappingvalidate_argumentsfirst.permit.sync.Permitoverrides the three enforcement methods it was missing.parse_obj_asis imported through the same v1/v2 guard the rest of the package uses.bulk_checkhonours a per-checkcontext, andfilter_objectspasses the caller's context through.CheckQuery.contextisNotRequired, so type checkers accept abulk_checkquery without a context, as the runtime always has.UserInputaccepts snake_case, sofirst_name/last_nameare no longer silently dropped from every check.datetime/UUID/Enumno longer crashes inside aiohttp.exclude_noneis gone.Python 3.14 support
Programming Language :: Python :: 3.14) and tested. The dependency floors above keep resolvers from picking versions that crash on 3.14.permit/utils/deprecation.pyusesinspect.iscoroutinefunctioninstead of the asyncio one, which 3.16 removes. This removes 21 import-time warnings on 3.14.2.14.0b2. They used to crashimport permitwith aValueError.compatibilityCI job runs the offline suite on Python 3.10–3.14 against the lowest allowed dependency versions (uv pip compile --resolution lowest-direct), the lowest allowed pydantic 2 (lowest-direct with apydantic>=2constraint) and the newest, plus 3.14 on pydantic 1. uv is pinned to 0.12.18. It is not a required check, so the existing requiredpytestcontexts are unchanged.Typed public surface
if TYPE_CHECKING:branch, and mypy uses thepydantic.v1.mypyplugin.Field(default=...)), so optional fields no longer read as required to pyright and Pylance.generate-modelspasses--use-default-kwarg.ModelInput/ModelListInputwiden the type for type checkers only; at runtime the parameter is still the model, so an invalid dict still fails validation before any request is sent.permit/_sync_types.pyi, built byscripts/generate_sync_stubs.py(make generate-sync-stubs). A test fails if the stub drifts from the async classes.mypy --strictand pyright:stris accepted forEmailStrfields;UserInputaccepts both field spellings;permit/__init__.py;deprecated()keeps the decorated signature;PermitConfig()without atokenis now a type error, as it already was at runtime.permit/py.typedand the stub ship in the wheel and sdist, and both the release build and one compatibility leg assert that they do. The README has a short "Type checking" section.@validate_argumentsmodel and model field (defaults and aliases included) matches the previous commit on both pydantic majors and on Python 3.11 and 3.14.Audit-log models (PER-14375)
permit/api/models.pychanged. They now match what the pinned generator (datamodel-code-generator 0.33.0) emits from the current public API schema. The rest of the file is unchanged; a full regeneration belongs to PER-16236.tests/test_fix_audit_logs.pyparses logs withpdp_config_idnull or missing, detailed logs withoutobjects, and GENERIC-engine logs.Relationship-tuple and API-key models (PER-16334)
APIKeyOwnerType,RelationshipTupleReadandRelationshipTupleDetailedReadnow match what the pinned generator emits from the current public API schema; the rest ofpermit/api/models.pyis unchanged.tests/test_fix_read_models.pyparses a wildcard tuple (object_idnull and absent) throughrelationship_tuples.list()andcreate(), detailed tuples with and without detail blocks, and an API key owned bynats_pdp_config.Minor bug fixes
every client now sends
Authorization: Bearer(wasbearer), checked on the wire by an offline test ·resource_instances.list(detailed_key=True)always raised ·users.sync()mutated the caller's dict and removed thekeyfield the API requires, so that path always returned 422 ·SyncPDPApinever calledsuper().__init__·pdp_timeoutwas silently ignored by everypermit.pdp_api.*call · a dead access-level branch that could never run was removed · docstrings corrected: resource-instance idents areresource:keyor a uuid, never a bare key; a resource role's permissions are bare action keys likeread.Packaging and cleanup
setup.pyno longer ships a top-leveltestspackage to consumers. A barefind_packages()put it in their site-packages, where it shadows their owntestsmodule. The publishedpermit==2.8.3does this today.ClientConfig/pagination_paramscode inpdp_api, a duplicate_model_dump, and unused TypeVars and helpers..isort.cfg(isort isn't run), a stubuv.lockdeclaringrequires-python >=3.14, and the Makefilepublishtarget, which bypassed the gated release. Fixed the Makefile's.DEFAULT_GOAL, which pointed at a target that didn't exist. Fixed a.gitignorerule that did nothing.support@permit.io) instead of an individual. The field is informational and does not affect publishing.CVE gates
Dependency Auditruns Trivy over four resolved trees: runtime ceiling, runtime floor, runtime floor with pydantic held to 2, and dev.test_pydantic_requirement_allows_no_release_affected_by_cve_2024_3772is what blocks pydantic 2.0–2.4.1; the tree gives visibility.==pins and keys on the filenamerequirements.txt, so the trees are compiled withuv pip compile. It also writesResults: nulland exits 0 when it finds nothing to scan, so that case is detected and fails the gate.mypypulledtyping-extensionsup and hid the version a consumer can actually get.setup.pycode never runs in a job that holds a write token.build → scan → publishwith hardneeds:edges, so publish can't run unless the scan passed. The gate covers the runtime trees only.SLACK_WEBHOOK_URLisn't set.pip-auditaudits each of the four compiled trees directly (--no-deps --disable-pip), alongside Trivy. It only reports and never blocks, because it gives no severity. If it cannot check a tree, the PR comment, the Slack message and the job summary say so.versioning-strategy: increaseis required. With asetup.pypresent, Dependabot's defaultwidenwould never raise a>=floor.Workflow hardening
persist-credentials: falseeverywhere, least-privilegepermissions:, template injection removed, and the release-tag validation now checks the whole string.upload-artifactv7.0.1,download-artifactv8.0.1 andslack-github-actionv4.0.0. The pre-commit job runs pre-commit directly, because the latestpre-commit/actionrelease pins a Node 20 cache action.ubuntu-24.04instead ofubuntu-latest, which moves to Ubuntu 26 in October.pytest.ini, so the SDK's pytest settings don't apply to them..github/scripts/check_schema_drift.pygenerates models fromhttps://api.permit.io/v2/openapi.jsonwith the pinned generator and compares them withpermit/api/models.pyby structure: classes, fields, types, required or optional, defaults, aliases,Config.extraand enum members..github/scripts/schema_drift_allowlist.json, one reason each (22 today). An entry that no longer matches fails until it is removed..github/workflows/schema-drift.ymlruns weekly, on manual dispatch, and on pull requests that change the models, the script, the allowlist or the workflow. It is not a required check. A scheduled run that does not pass posts counts and a link to Slack; a manual run always posts.make generate-modelspins its generator: datamodel-code-generator 0.33.0, the release that builtmodels.py, with--exclude-newer 2025-09-18T00:00:00Zand Python 3.11, run throughuvx, so contributors need uv.release.yml. It ran onrelease: createdwhilepython-sdk-publish.ymlran onpublished, so every release uploaded the same version twice.PROJECT_API_KEY. The tests authenticate with the per-run environment key, the PDP rejected every decision with a 403, and that is why the RBAC/ReBAC decision tests could never pass.Test suite
xfailmarkers removed. Those tests now run and pass.admin,viewer, a sharedurn), assert environment-wide counts, and fail the test when cleanup got a 404. Each test now uses unique keys, asserts only on its own objects, and tolerates "already gone" during teardown.conftestfor the test session only. Requests retry with backoff, honourRetry-After, and add jitter. The xfail markers had been hiding these 429s. The SDK itself doesn't retry: adding hidden retries to a published client would change behaviour callers never asked for.test_bulk_operationsfixed. It expected a role assignment to survive deleting the user who owns it.httpserver_listen_addresslives inconftest.pyand binds a free port. Its port used to depend on which test file pytest collected first, and parallel local runs collided.tests/test_fix_sync_parity.pywalkspermit.Permitandpermit.sync.Permitand fails if a sub-API or method exists only on the async client, if something callable there isn't callable on the sync one, or if anything reachable from the sync client is a coroutine function. It replaces a hard-coded list of five names.resource_actionsandresource_action_groups(async and sync, every method), all 21 deprecated facade methods (request, result and 4.0 warning), and the audit-log models.e2emarker. Tests that need credentials, the API or a PDP are markede2e, sopytest -m "not e2e"runs everything else with no setup. The compatibility job selects tests this way instead of by file name.resource:keyinstead of leaking it.tests/utils.pyholds the offlinePermitConfigand request-capture helpers that the offline test files share.ResourceCreateandRelationshipTupleCreate;users.updatesends a field set toNoneasnull;proxy_facts_via_pdp, each single-object write (users.create,tenants.create,resource_instances.create,relationship_tuples.create,role_assignments.assign,users.assign_role) goes to its own PDP/facts/...route;users.getkeeps each attribute's JSON type and its nulls;pydanticnamespace outside its pydantic 1 branch;get_user_permissionsunwraps both PDP response shapes;projects.createwith an environment key is refused before any request;delete_tenant_user,environments.copyanduser_invites.getsend the documented request, and an unknown invite raises a 404PermitApiError.Architectural changes
No architectural change to the SDK. The release job graph changes:
flowchart TD subgraph After["After: publish is unreachable without a passing scan"] B2["build: version, sdist and wheel"] --> S2["scan: compile trees, Trivy, gate"] S2 --> P2["publish: PyPI"] end subgraph Before["Before: two workflows raced"] R1["release.yml on 'created'"] --> PY1["twine upload"] R2["python-sdk-publish.yml on 'published'"] --> PY2["pypi-publish"] endHow it was tested
CI: all 26 checks are green:
308 passed, 7 skippedon both required pydantic legs. At the start of this PR it was 45 passed with 8 permanently xfail.compatibilitylegs (Python 3.10–3.14: lowest dependencies, lowest pydantic 2, newest dependencies, plus 3.14 on pydantic 1) are green, with 291 offline tests each.The 7 skips:
test_abac_e2e, waiting on PER-16209. The control-plane half of that test still runs.End-to-end harness (internal) against a local Permit stack with a real
permitio/pdp-v2:58 passed, 0 failed, 0 skipped, with 95 data-integrity round-trips and 0 differences.Against the API: every wire-affecting change was checked against the API's route and request/response definitions. Every one was safe.
Offline:
pytest -m "not e2e"), green on pydantic 1.10.26 and 2.13.5, on every Python from 3.10 to 3.14, and on the pydantic 2 floors (2.4.2 on 3.10–3.12, 2.8.0 on 3.13). Checked that they're real: revertingpermit/makes them fail.mypy --strictas part of the suite, and also passes pyright strict against the installed wheel. Each typing fix was mutation-checked: undoing any one of them makes the fixture fail.The gate itself: it fails (exit 1) on the old vulnerable floor and passes (exit 0) on the fixed one.
The weekly audit: a manual run of the Security workflow on this branch finished with no warnings; pip-audit checked all four trees and the Slack message was posted.
Manual test plan
Dependency Auditposts a sticky comment on this PR.aiohttp>=3.12.14,<4. The check should go red and the comment should list CVE-2026-69244. Revert it.gh workflow run security.yml --ref <branch>to run the weekly audit on demand; it posts to Slack.pip installthis branch and runmypy --stricton code that usespermit.Permitandpermit.sync.Permit. Expect no errors, and sync calls typed as their results rather than coroutines.Blast radius and isolation
permit;Follow-ups
Already applied outside the diff: secret scanning with push protection, Dependabot security updates, and
Dependency Audit/Audit Script Tests/Workflow Hardeningadded as required checks onmain.Still open:
Scope and size
permit/_sync_types.pyicomes on top.🤖 Generated with Claude Code