Skip to content

Use PyPI trusted publishing, pin the e2e PDP, add latest and cloud PDP jobs - #136

Draft
zeevmoney wants to merge 68 commits into
mainfrom
per-16676/release-ci-hardening
Draft

zeevmoney wants to merge 68 commits into
mainfrom
per-16676/release-ci-hardening

Conversation

@zeevmoney

@zeevmoney zeevmoney commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Linear issues

  • PER-16676: Harden the release and CI workflows: trusted publishing, release-tag protection, pinned PDP, latest-PDP and cloud-PDP e2e jobs.

Stacked on #128.

Why

  • Releases uploaded to PyPI with a long-lived API token stored as a repository secret.
  • The required e2e jobs ran against permitio/pdp-v2:latest, so a new PDP release could fail every pull request.
  • No CI job ran the cloud-PDP tests in tests/test_abac_pdp.py. They assumed the cloud PDP answers 501 to ABAC calls, which it no longer does, and set up no policy of their own.
  • Three gaps on the release path:
    • The release scan could take its Trivy binary from the Actions cache.
    • Nothing checked that only the permit package ships.
    • No job had a timeout.

What changed

Release workflow (.github/workflows/python-sdk-publish.yml)

  • Publish to PyPI uploads by PyPI trusted publishing (OIDC).
    • It passes no password, keeps environment: pypi, and its only permission is id-token: write.
    • The zizmor: ignore[use-trusted-publishing] and its TODO are removed.
    • The job comment names the three things PyPI matches: repository, workflow file and environment. It also says PyPI does not check the ref, so the pypi environment's deployment rules are what limit uploads to release tags.
  • Security Gate runs the Trivy action with cache: false, so every release downloads Trivy and checks its checksum.
  • Build distribution, step "Check the wheel and sdist contents", also fails when:
    • the wheel's top level holds anything other than permit/ and permit-<version>.dist-info, or
    • the sdist holds a directory other than permit/.
  • Timeouts: build 10, scan 15, publish 10 minutes.

Already in place and unchanged:

  • One publish path: build → scan → publish, joined by needs:. The scan gates the publish and keeps its report for 90 days.
  • py.typed and _sync_types.pyi are asserted in both artifacts.
  • The tag is validated as a whole string, passed through env.
  • The workflow triggers on release: published.
  • No setup-uv cache, and the build uv is pinned by version and checksum.
  • persist-credentials: false, top-level contents: read, ubuntu-24.04, and SHA-pinned actions.

CI (.github/workflows/test.yml, .github/workflows/security.yml)

  • Every PR gets the full checks. The Test and Security workflows ran only on PRs into main/master, so a stacked PR, whose base is another PR's branch, got no tests, dependency audit or workflow checks. The base filter is dropped. Push runs are unchanged.
  • Pinned PDP. The required pytest jobs run PINNED_PDP_IMAGE, permitio/pdp-v2:0.9.16@sha256:e3cf30794ec2d256636b4714641df46e51ee58a3f1f0d24c606e214e0bf8669a, the multi-arch index digest. Job name, matrix and step names are unchanged.
  • New job e2e-unpinned-pdp. It is not a required check and starts after both pytest lanes pass. It has two legs:
    • e2e (latest PDP image) runs the whole suite on pydantic 2 against permitio/pdp-v2:latest and logs the digest :latest resolved to.
    • e2e (cloud PDP) runs tests/test_cloud_pdp_e2e.py with PDP_URL=https://cloudpdp.api.permit.io and no container. It fails if any test is skipped, which a check on the junit report enforces.
  • Each new leg creates its own scratch environment, python-sdk-ci-<run_id>-<run_attempt>-<leg>. It masks the key before export and deletes the environment in an always() step. The leg keeps the 300 s PDP wait and the filtered PDP log, and has the same permissions, pinned actions, persist-credentials: false and env-passing as pytest.
  • compatibility job. Its artifact check is now the same script as the release check.
  • Timeouts: pytest 30, compatibility 15, e2e-unpinned-pdp 30 minutes.

Tests

  • tests/test_abac_pdp.py is replaced by tests/test_cloud_pdp_e2e.py. It creates its own resource type (actions read and write), a role granting read, two tenants, a user and a role assignment, waits until the cloud PDP allows read, then asserts exact decisions from check, bulk_check, get_user_permissions and filter_objects, including the denials.
  • A new offline test checks that a PDP answering 501 to check, get_user_permissions or filter_objects makes the SDK raise PermitConnectionError with the status in the message.

Docs

  • CONTRIBUTING.md has a new "Releasing" section.
  • "End-to-end tests" now describes:
    • the four e2e jobs;
    • how to reproduce the required jobs on the pinned image;
    • the cloud-PDP command;
    • "Moving the PDP pin".

Behaviour changes

  • Releases upload through OIDC and no longer read PYPI_TOKEN. The trusted publisher must be registered on pypi.org first.
  • The release scan no longer restores Trivy from the Actions cache.
  • A release, or a pull request's compatibility run, fails if the wheel or sdist ships anything besides permit. Today's build passes.
  • The required e2e jobs run PDP 0.9.16. On 2026-09-28 that was the same digest as :latest.
  • There are two new non-required check runs. A Test run can create up to four scratch environments, two at a time.
  • The build, scan, publish, pytest, compatibility and new e2e jobs have timeouts.
  • e2e (cloud PDP) passes only if the cloud PDP returns the expected allow and deny decisions for the policy the tests create.

How it was tested

  • uv lock --check passes.
  • pre-commit run --all-files: all hooks pass.
  • mypy reports no issues in 89 files, on both pydantic 2 and pydantic 1 (1.10.26).
  • pytest -m "not e2e": 308 passed, 3 skipped, no warnings, on pydantic 2 and on pydantic 1.
  • actionlint reports nothing.
  • zizmor .github/ with online audits reports no findings and no ignores. Before this PR it had one ignore, for trusted publishing.
  • Check names. Expanding every matrix gives 20 check runs before and 22 after. None were removed; the two added are e2e (latest PDP image) and e2e (cloud PDP). Both required pytest (Pydantic …) contexts are unchanged.
  • Cloud-PDP selection. pytest --collect-only collects the 3 tests. --setup-plan -rs skips all 3 with PDP_URL=http://localhost:7766 and skips none with PDP_URL=https://cloudpdp.api.permit.io.
  • 501 handling (offline). One test per method (check, get_user_permissions, filter_objects) against pytest-httpserver answering 501. Six mutations of the enforcer, treating 501 as success or dropping the status from the message, each fail exactly the case they target.
  • No-skip gate. On a junit report with 3 skipped tests it exits 1; with 12 tests run it exits 0. A mutant without the check passes the skipped report.
  • Contents check. It passes a local build of this branch and fails a wheel with a planted tests/ package. A mutant without the exit lets that wheel through. The release and compatibility copies are identical.
  • PDP digest. The Docker Hub tag API returns the pinned digest for 0.9.16 as an OCI image index.
  • Not run locally. The e2e suite needs CI secrets; it runs in this PR's CI. e2e (cloud PDP) is not a required check.

Owner actions before merge

  1. On pypi.org, under project permit, Manage, Publishing, add a GitHub Actions trusted publisher: owner permitio, repository permit-python, workflow python-sdk-publish.yml, environment pypi.

  2. Create a tag ruleset that lets only admins create, move or delete tags matching refs/tags/v[0-9]* and refs/tags/[0-9]*. Save the JSON below as release-tag-ruleset.json and run gh api repos/permitio/permit-python/rulesets -X POST --input release-tag-ruleset.json.

    release-tag-ruleset.json
    {
      "name": "Release tags: admins only",
      "target": "tag",
      "enforcement": "active",
      "bypass_actors": [
        { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" },
        { "actor_id": 1, "actor_type": "OrganizationAdmin", "bypass_mode": "always" }
      ],
      "conditions": {
        "ref_name": {
          "include": ["refs/tags/v[0-9]*", "refs/tags/[0-9]*"],
          "exclude": []
        }
      },
      "rules": [
        { "type": "creation" },
        { "type": "update", "parameters": { "update_allows_fetch_and_merge": false } },
        { "type": "deletion" }
      ]
    }
  3. Protect the pypi environment:

    • allow deployments only from tags matching v[0-9]* and [0-9]*;
    • require a release reviewer;
    • turn off admin bypass.

After the first release published by OIDC succeeds, delete the PYPI_TOKEN secret and revoke that token on pypi.org.

🤖 Generated with Claude Code

zeevmoney and others added 30 commits September 21, 2026 17:19
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>
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
pyproject.toml now carries the PEP 621 metadata setup.py declared, built
with uv_build; dev tools move to a PEP 735 group and both pydantic lanes
become conflicting groups, so every CI lane installs from the committed
uv.lock. setup.py, requirements*.txt, MANIFEST.in, pytest.ini and the
Makefile are gone; contributor docs move to CONTRIBUTING.md.

CI installs with uv sync --locked; the publish job stamps the version
with uv version, builds with uv build --no-sources on a checksum-verified
uv, and keeps its build -> scan -> publish gating and PyPI token auth.
The audit compiles its three trees from pyproject.toml with --no-sources
and fails if the dev group did not resolve. uv is pinned once, by
[tool.uv] required-version, with a 7-day exclude-newer cooldown;
Dependabot uses the uv ecosystem and a uv-lock hook stops drift.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
ruff 0.16.7 with select = ["ALL"] minus justified ignores, line length
100 and Google docstrings; mypy 2.3.1 strict over permit/, tests/ and
.github/scripts on both pydantic majors, with TYPE_CHECKING branches so
the v1 models type-check as v1 under pydantic 2. ruff, mypy and typos
run as local pre-commit hooks from uv.lock (uv run --locked), external
hooks are SHA-pinned, pytest runs strict with warnings as errors, and
Dependabot covers pre-commit with lint tools grouped apart from runtime
floors.

No public API or behaviour change; runtime-visible aliases, bare-dict
fields and the star-import surface are kept identical. Three bugs the
stricter checks exposed are fixed with regression tests: decimal_encoder
crashed on NaN/Infinity, a pre-release pydantic version crashed
import permit, and import permit raised under -W error because
PermitConnectionError subclasses the deprecated PermitException.

py.typed is deliberately not shipped yet (PER-16231).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Main gained the 3.0.0 SDK fixes (#126) and the final uv migration
(#127) after this branch was cut. The branch reformatted and strictly
typed the pre-3.0.0 code, so the merge conflicted in most files.

The tree is reset to main's tree here, so the tooling, formatting and
typing changes can be re-applied on top of the 3.0.0 code in separate
commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ruff 0.16.8 with `select = ["ALL"]`, line length 100, Google docstring
  convention and docstring-code-format. Every ignore is justified in
  pyproject.toml. The generated permit/api/models.py and the migration
  skill's sample apps stay out of lint and format (force-exclude), and the
  migration scanner is held to Python 3.8 syntax.
- mypy 2.3.1 `strict`, plus warn_unreachable and extra error codes, over
  every Python file but the generated models and the sample apps, with the
  pydantic.v1 mypy plugin on both pydantic majors.
- typos 1.50.2 checks spelling.
- pytest runs with `strict = true`.
- ruff, ruff-format, mypy and typos are `repo: local` pre-commit hooks
  running `uv run --locked`, so uv.lock is the only source of their
  versions. pre-commit-hooks v6.0.0 is pinned by SHA and adds
  check-shebang-scripts-are-executable.
- CI type-checks once more under pydantic 1.
- Dependabot gets a pre-commit ecosystem entry, and ruff, mypy and typos
  a group of their own in the uv entry.

The code is reformatted and fixed in the following commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deprecation-warning test expected each warning on the line after its
helper's `def`, which stops being true once the formatter wraps the
helper's signature. Read the line of the helper's one statement from its
syntax tree instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical: `ruff format` with the configuration from the previous
commit. The sync stub generator lays out permit/_sync_types.pyi the way
ruff format does at a given line length, so its LINE_LENGTH moves to 100
and the stub is regenerated; the result is what ruff format produces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generator only resolved names brought in with `from module import`.
An annotation such as `builtins.list[str]`, which a class that defines a
`list` method needs, refers to a module imported whole with
`import builtins`; the generator now emits that import in the stub, in
the order ruff's isort rules use. The committed stub does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical: `ruff check --fix` (safe fixes only), then `ruff format`, and
the sync stub regenerated from the fixed classes. Most of it is PEP 585
and 604 annotations, docstring layout, else-after-return and sorted
imports.

Three rewrites would have changed runtime objects, so those sites keep
their spelling with a noqa that says why:
- `Context` and `AuthorizedUsersDict` are public aliases, so they stay
  `typing.Dict` generics rather than becoming builtin ones.
- `UserInput.attributes`, `ResourceInput.attributes` and
  `ResourceInput.context` stay `typing.Dict`: pydantic v1 validates a
  `typing.Dict` value into a copy but keeps the caller's object for a
  bare `dict`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The safe fixes rewrote the `IncEx` alias in permit/api/encoders.py with
builtin generics (`set[int]`, `dict[str, Any]`), which changes the
runtime object the alias names. Restore the `typing` generics it had,
with a noqa, as for `Context` and `AuthorizedUsersDict`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK now passes `ruff check` and strict mypy under both pydantic
majors. Most of it follows the approach of the original PR-128 commit:
- absolute imports, return and parameter annotations, `ParamSpec` on
  `handle_client_error`, `@overload` on `delete()`, and Google
  docstrings on the public API (the `Sync*` runtime classes included);
- `TModel` is no longer bound to `BaseModel`, since list endpoints
  parse into `list[Model]`, and the unused `TData` is removed;
- equivalent rewrites the rules ask for: HTTPStatus constants, messages
  assigned before `raise`, `input` renamed where it shadowed the builtin.

Runtime-visible spellings are kept, with a suppression that says why:
the `User`, `Resource` and `_UserSyncInput` aliases, the bare-`dict`
pydantic fields, `PermitConnectionError`'s deprecated base, and the
positional signatures of four `list()` methods (PLR0917).

`UserInput.attributes`, `ResourceInput.attributes` and
`ResourceInput.context` become `dict[Any, Any] | None`, which pydantic
v1 validates exactly like the `Optional[Dict]` they were: into a copy
of the caller's dict. A new test fails if they ever become a bare
`dict`, which keeps the caller's object and lets the tenant the SDK
adds leak into it.

Tooling that goes with it:
- PLC0414 is off: `import X as X` is the explicit re-export strict
  mypy needs, and the SDK uses it for the blocking classes in
  permit/_sync_types.pyi.
- The stub's copied docstrings are allowed (PYI021).
- The stub generator accepts a docstring in the `Sync*` runtime classes,
  and the stub is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The org-level environment test created each environment under `project`,
the variable its project loop left behind, which is unbound when the loop
does not run and otherwise names whichever project came last. The
assertions that follow check `projects[0]`. Create the environments in
`projects[0]` too. mypy reports the old line as possibly undefined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project-level environment test kept the context's project ID and
the project it then looked up in one variable, `project`. When the
lookup failed, the `finally` block ran `cleanup(permit, project.key)`
on the ID string and raised AttributeError, which replaced the lookup's
own error. Look the project up before the `try`, under its own name:
until it is known nothing has been created, so there is nothing to
clean up. mypy reports the reused variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests now pass `ruff check` and strict mypy under both pydantic
majors, mostly following the approach of the original PR-128 commit:
return and parameter annotations, absolute imports, typed helpers
(`find_by_key` is generic over keyed models), `is not None` asserts
where an optional field is read, and `tests/endpoints/__init__.py` so
those modules are part of the tests package.

A few rewrites the rules ask for:
- try/except blocks that only checked an expected error become
  `pytest.raises`; `test_error_response` used to pass when no error was
  raised at all;
- `pytest.warns` calls name the warning they expect;
- loop-bound lambdas become `functools.partial`, and loop variables that
  shadowed an outer name are renamed.

The dict-input `type: ignore`s are gone: `ModelInput` lets type checkers
accept dicts. What stays suppressed, and why:
- `tests.test_fix_sync` declares its own `SyncClass` classes, whose
  methods mypy reads as coroutines (a module override for
  comparison-overlap and unused-coroutine);
- validate_arguments' `raw_function` and a test decorator's `__wrapped__`
  are set at runtime and absent from the types;
- `tomli` exists only on Python 3.10;
- S603 is off in the tests, which run the interpreter under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CI scripts in .github/scripts, their tests and
scripts/generate_sync_stubs.py now pass `ruff check` and strict mypy.
Mostly annotations, docstrings and messages assigned before `raise`,
following the approach of the original PR-128 commit, plus:
- format_audit.py and check_schema_drift.py are executable, as their
  shebangs say (check-shebang-scripts-are-executable);
- the schema download's success path moves to the retry loop's `else`,
  with a new test that a download which succeeds at once is not
  repeated (the existing retry test cannot tell);
- the stub generator's `resolve` and `class_lines` hand their import
  bookkeeping to two helpers, and the stub it writes is unchanged;
- the tests patch `urllib.request` and `time` directly rather than
  through the script's module, the same objects.

Suppressed with a reason: C901 on functions that are one linear pass
(the drift check's model parser, format_audit's `render` and `main`),
PLR0917 on `Finding`, S603 where a script runs a fixed command, S310
on the http(s)-only schema download, and PERF203 on its retry loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zeevmoney and others added 10 commits October 1, 2026 05:22
Brings in per-16676/impl-ci: the required e2e jobs run a PDP image
pinned by version and digest, two jobs that are not required checks
run the e2e tests on the latest PDP image and on the cloud PDP, and
the pytest and compatibility jobs get timeouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The compatibility job in test.yml now runs the same artifact check as
the release build, so a packaging change that ships a package beside
permit fails a pull request, not the next release. The two scripts are
identical, and each workflow points at the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment read as if PyPI took uploads for permit only from this
publisher, which is not so while a project API token exists. It now
says what makes this job's own upload accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRIBUTING.md gets a Releasing section: which tags the release
workflow accepts, what each of its three jobs checks, that the upload
uses PyPI trusted publishing, and which names on pypi.org must change
with the workflow file or the environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRIBUTING.md now says which e2e jobs CI runs and which are
required, how to reproduce them locally on the pinned PDP image, the
latest one or the cloud PDP, and how to move the PDP pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The module comment said CI only ever skips these tests. The new
e2e (cloud PDP) job runs them and fails if any is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three tests in tests/test_abac_pdp.py passed on any
PermitConnectionError, which the SDK also raises for a rejected key, a
server error and a PDP it cannot reach. The e2e (cloud PDP) job was
then green without the cloud PDP ever answering. Each test now
requires the status code 501 in the error message, and the workflow
comment and CONTRIBUTING.md say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text said three, but the list under it names four: the two pytest
lanes, e2e (latest PDP image) and e2e (cloud PDP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyPI trusted publishing matches the repository, the workflow file name
and the environment, not the ref, so a branch that edits the publish
workflow to run on push could upload. The publish job's comment and
the Releasing section now say that the pypi environment's deployment
rules, a repository setting, are what prevent that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both workflows ran only on PRs into main or master. A stacked PR, whose
base is another PR's branch, got no tests, no dependency audit and no
workflow checks until it was retargeted. Drop the base filter so every
PR gets the same checks before it merges. Push runs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 1, 2026

Copy link
Copy Markdown

PER-16676

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, 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.

zeevmoney and others added 14 commits October 1, 2026 12:53
A PDP that does not implement check, get_user_permissions or
filter_objects answers 501. The SDK must then raise
PermitConnectionError with that status in the message, not return a
decision. The cloud PDP tests asserted this against the hosted PDP,
which now answers these calls. This test keeps the contract covered
against a local pytest-httpserver PDP.

The status is matched where each message reports it, since the message
also holds the PDP's URL, whose random port can contain 501.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cloud PDP tests expected the hosted PDP to answer check,
get_user_permissions and filter_objects with 501. It now answers them
with 200, so all three failed the first time CI ran them.

tests/test_cloud_pdp_e2e.py replaces tests/test_abac_pdp.py. Each test
creates a small RBAC policy with per-run keys in the scratch
environment: a resource type with two actions, a role that grants one
of them, a tenant where the user has that role and a tenant where it
has none. It waits, with a bounded poll, until the cloud PDP allows the
granted action, then asserts the exact answers of check, bulk_check,
get_user_permissions and filter_objects. Teardown deletes every object
it created and treats a 404 as success.

The module still skips unless PDP_URL is the cloud PDP. The
e2e (cloud PDP) job now runs the new path and still fails if any of
its tests is skipped. The workflow comments and CONTRIBUTING.md
describe what the job now tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cloud PDP lists its built-in "tenant-association" role after the
roles assigned to a user who belongs to the tenant. The first CI run
against it returned ["<role>", "tenant-association"] on every poll, so
the expected value now includes it. check, bulk_check and
filter_objects already matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The invites target a resource instance, and the API now refuses to
approve an invite whose role belongs to another resource (PER-15743).
The test gave them a tenant role; it now creates a role on the invited
resource and deletes it before the resource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PDP can allow a check before its list of role assignments shows
the grant, so the tests read that list once and sometimes saw none.
They now poll for it, as they do for decisions, before asserting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PDP can allow a check before its authorized-users answer lists the
user, so the test read that answer once and sometimes saw no users. It
now polls for the user, as it does for decisions, before asserting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ruff's UP006 fix changed ModelListInput[X] at runtime from
typing.List[X] to list[X]. The two do not compare equal, so
get_type_hints() on the bulk methods' undecorated functions returned a
different annotation than in 3.0.0. Validation was unaffected.

Return typing.List[X] again, and restore the regression test's
assertion that the annotation equals List[UserCreate].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait loop's curl had no timeout, so a PDP that accepted the
connection and never answered could hold the step indefinitely. Each
probe now gives up after 5s.

On failure, the PDP log filter dropped every "Health check failed:
horizon" line, including the one that says why the PDP never became
healthy. Keep the first such line; the later repeats and the GET
/health requests are still dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With each probe allowed 5s, 300 tries could take far longer than the
300s the error message reports. The loop now stops once 300s have
passed, whatever the probes took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The second PDP job copies the pytest job's wait and failure-log steps,
so it gets the same 300s elapsed-time bound, the 5s probe timeout, and
the first horizon health-check failure kept in its log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The invites target a resource instance, and the API now refuses to
approve an invite whose role belongs to another resource (PER-15743).
The test gave them a tenant role; it now creates a role on the invited
resource and deletes it before the resource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live spec renamed ApproveMessage's only field from message to
detail, so the schema drift check failed on it. No SDK method returns
this model. The class is the generator's output, copied unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from per-16222/strict-tooling to main October 2, 2026 18:24
@zeevmoney
zeevmoney added this pull request to stack #145 October 2, 2026 18:24
main is the squash of #128, whose commits this branch already has, so
the tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant