Skip to content

Add tenants.create_user and get_user_tenants - #139

Draft
zeevmoney wants to merge 19 commits into
per-16677/groups-apifrom
per-16678/tenant-membership
Draft

zeevmoney wants to merge 19 commits into
per-16677/groups-apifrom
per-16678/tenant-membership

Conversation

@zeevmoney

Copy link
Copy Markdown
Contributor

Linear issues

  • PER-16678: Add tenants.create_user (with add_user as a deprecated alias) and get_user_tenants to the Python SDK.
  • PER-16710: The PDP leaves members with no role out of /user-tenants; the SDK documents and tests the current behaviour.
  • PER-16337: Python SDK parity with the other SDKs; this PR covers P1 items 2 and 5.

Why

The SDK could list a tenant's users and remove them, but it could not add a user to a tenant through the tenant users route. It also had no way to ask the PDP which tenants a user belongs to. The Java SDK has the second as getUserTenants.

What changed

  • permit.api.tenants.create_user(tenant_key, user_data) -> UserRead, on the async and blocking clients. It posts the user (UserCreate or an equivalent dict) to /v2/facts/{proj}/{env}/tenants/{tenant}/users. It always calls the Permit REST API, even with proxy_facts_via_pdp, because the PDP forwards this route without waiting for the policy sync. The API creates a new user who belongs to the tenant with no role. Any role_assignments in the user data are granted in the tenant each one names. An existing key gets 409 (PermitAlreadyExistsError). A missing tenant, or a missing role, tenant or resource named by an inline grant, gets 404 (PermitNotFoundError). It needs an environment-level API key, or a broader key with the SDK's API context set to the environment. TenantsApi builds this client through a new API-only property. The routing of its other methods is unchanged.
  • permit.get_user_tenants(user, context=None) -> list[TenantDetails], on permit.Permit, permit.sync.Permit and the enforcer. It posts {"user", "context"} to the PDP's /user-tenants route. The user is serialized as check() serializes it, and the context is merged over the context store. The PDP lists the tenants in which the user holds a tenant-level role. A member with no role is not listed. Only the container PDP serves the route. When the PDP answers 404, as the cloud PDP does, the SDK raises a PermitConnectionError saying the call needs a container PDP. Any other error status raises a PermitConnectionError with the status and the redacted error body, as check() does. A PDP that cannot be reached raises the same connection error as the other PDP calls.
  • permit.api.tenants.add_user() is a deprecated alias of create_user(): it issues one DeprecationWarning at the caller's line, then calls create_user(). It will be removed in permit 4.0. The README lists it under Deprecations.
  • TenantDetails(key, attributes) in permit.enforcement.interfaces, exported from permit next to AuthorizedUsersResult.
  • The delete_tenant_user docstring now says what the API does. It removes the user's tenant-level roles in the tenant. It answers 404, raised as a PermitApiError, when the user holds none there, which includes a member that create_user() created; such a member is removed with users.delete(). The user stays a member while they hold a role in another tenant. The API deletes the user once no tenant-level role is left in any tenant.
  • A "Tenant membership" section in the README, after Groups. The sync stub (permit/_sync_types.pyi) is regenerated, and tests/type_check/consumer.py calls both methods on both clients.
  • Tests:
    • tests/test_tenant_membership_offline.py: 71 offline wire tests.
    • tests/test_tenant_membership_e2e.py: 3 e2e tests. The two that call get_user_tenants skip, with a reason, when the PDP is the cloud PDP.
    • tests/test_cloud_pdp_e2e.py: a new test checks that the cloud PDP's 404 for get_user_tenants raises the container-PDP error.
    • tests/test_fix_logging.py: the API key redaction test now also covers get_user_tenants.
    • tests/utils.py: now holds CLOUD_PDP_URL and delete_quietly_blocking, shared with the groups e2e tests.
  • CONTRIBUTING and the e2e (cloud PDP) workflow comment describe the new tests.

Behaviour changes

None for existing methods. create_user, its deprecated alias add_user, get_user_tenants and the TenantDetails export are new. delete_tenant_user changes only in its docstring.

How it was tested

  • Offline suite: 545 passed, 3 skipped on pydantic 2 and on pydantic 1, with 0 warnings (warnings are errors). The base had 472 passed and 3 skipped.
  • Strict mypy on both pydantic lanes: no issues in 96 source files. tests/test_typing_surface.py (stub drift, consumer type check): 3 passed on both lanes.
  • pre-commit run --all-files (ruff, ruff format, mypy, typos, uv lock --check), actionlint and zizmor: no findings.
  • The offline wire tests assert the exact method, path, query, headers and JSON body of each call. They cover create_user with proxy_facts_via_pdp on and off, and get_user_tenants with a string user and a dict user (with attributes), with and without context. They also cover a PDP 404, a PDP 500, an unreachable PDP and the parsed return types.
  • Mutation check: the alias test fails when add_user stops warning or stops passing its arguments through. 21 mutants of the new code, all caught by the offline tests. They include create_user through the PDP proxy, wrong path or verb, a dropped body or context, a missing 404 branch, an unredacted or missing error body, an unparsed response, the sync override removed, the export removed and a stale stub. The cloud-PDP test passes only on a 404; it fails on 200, 401, 405, 500 and an unreachable PDP.
  • The e2e module collects and type-checks. It passes (3 passed) against a local in-memory stand-in for the API and PDP that behaves as the API does for tenant users. Eleven broken variants of that stand-in each make it fail. CI runs it against the real API and a container PDP.

Owner actions before merge

  • Check that the container-PDP pytest jobs and e2e (latest PDP image) pass. They are the first run of the e2e module against the real API and PDP.
  • PER-16710 tracks whether get_user_tenants should list members with no role and whether delete_tenant_user should remove them. If the PDP or API changes, update the docstrings, README and e2e assertion with it.

🤖 Generated with Claude Code

zeevmoney and others added 17 commits October 1, 2026 15:52
add_user makes a new user a member of a tenant with no role: the API
lists them with no roles, answers 409 for an existing key and 404 for a
missing tenant, and delete_tenant_user removes them. get_user_tenants,
on a container PDP, lists the tenants the user holds a role in, with
their attributes, and drops each one the user leaves. A role-less
member and a tenant the user never joined are not listed. The tests
that call the PDP skip when it is the hosted cloud PDP. One test runs
through the blocking client.

Refs PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.get_user_tenants(user, context=None), on the async and the
blocking client, posts {"user", "context"} to the PDP's /user-tenants
route and returns the tenants as TenantDetails models (key and
attributes). The user is serialized as check() does, and the context is
merged over the context store. TenantDetails is exported from permit,
next to AuthorizedUsersResult.

Only the container PDP serves the route; the cloud PDP answers 404. The
SDK raises that 404 as a PermitConnectionError saying the route needs a
container PDP. Any other error status raises a PermitConnectionError
with the status and the redacted error body, as check() does.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.api.tenants.add_user(tenant_key, user_data) posts a UserCreate
(model or equivalent dict) to /v2/facts/{proj}/{env}/tenants/{t}/users
and returns the UserRead. The API creates the user as a member of the
tenant with no role, unless user_data lists role_assignments, and
answers 409 when a user with that key already exists.

The request is sent to the Permit REST API whatever
proxy_facts_via_pdp says: the tenants API now has an API-only client
next to the one that follows the setting. The other tenants calls keep
their routing. The get_user_tenants docstrings now name add_user as a
membership without a role, which the PDP does not list.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_groups_e2e.py and test_tenant_membership_e2e.py each defined the
same delete_quietly_blocking, the blocking client's delete_quietly.
It now lives in tests/utils.py next to delete_quietly, and both modules
import it. Teardown behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_user_tenants documents that the cloud PDP does not serve
/user-tenants and that the SDK raises its 404 as a
PermitConnectionError naming the container PDP. The e2e (cloud PDP)
job runs only tests/test_cloud_pdp_e2e.py, so a test there now calls
get_user_tenants on the cloud PDP and checks for that error. It needs
no policy. The workflow comment and CONTRIBUTING.md list it with the
job's other checks.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The get_user_tenants tests in tests/test_tenant_membership_e2e.py skip,
with the reason, when PDP_URL is the cloud PDP, which does not serve
that query. The PDP_URL entry now says so next to where it says which
PDP each e2e module uses.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
conftest.py spelled the cloud PDP address out twice, and
test_cloud_pdp_e2e.py and test_tenant_membership_e2e.py each defined
it to decide when to run or skip. Those checks must use the address
the fixtures default to, so it is now one CLOUD_PDP_URL in
tests/utils.py that all of them import. Which PDP each test uses, and
when each skips, is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API removes only the user's tenant-level roles in the tenant. It
answers 404 when the user holds none there, including a member that
tenants.add_user() created, and the SDK raises that 404 as a plain
PermitApiError, not a PermitNotFoundError. A user who still holds a
role in another tenant stays a member with no role. When no
tenant-level role is left in any tenant, the API deletes the user,
which lets add_user() create a user with that key again.

Say so in the delete_tenant_user and add_user docstrings and in the
README, and point to users.delete() for removing a member with no
role. Regenerate the sync stub.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The add_user e2e test removed the member it had just created with
delete_tenant_user. The API answers 404 for a member with no role in
the tenant and leaves them in it, so the test would fail in the CI jobs
that run it against the API.

The test now checks that 404 and that the member stays. It then gives
the user a role in both tenants: delete_tenant_user in one tenant keeps
the user a member there with no role, and delete_tenant_user in the
other, the last role, deletes the user, so users.get answers 404. The
module docstring says the same.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API also answers 404 when a role assignment in the user data names
a role, tenant or resource that does not exist, and 409 when one names
a tenant other than the one its resource instance is in. Add both to
the Raises section and regenerate the sync stub.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README read as if role_assignments in the user data gave the user
a role in the tenant add_user names. The API grants each one in the
tenant it names, as users.create() does, which the add_user docstring
already says.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_tenant_details_gives_each_tenant_its_own_attributes passes with a
shared {} default too, because pydantic v1 copies field defaults per
instance on both lanes. The default itself stays covered by the
get_user_tenants parsing test, where a tenant with no attributes parses
to an empty dict.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_user_tenants puts the PDP's error body into the
PermitConnectionError it raises, as check() does. The test that pins
API key redaction for those errors covered check, bulk_check and
authorized_users only. Add get_user_tenants to it.

Part of PER-16678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tenant users route creates the user and answers 409 for an existing
key, so create_user says what it does. add_user stays as an alias that
warns once, at the caller's line, and calls create_user. It goes in
permit 4.0.

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

linear-code Bot commented Oct 1, 2026

Copy link
Copy Markdown

PER-16678

@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.

* origin/per-16677/groups-api:
  Invite with a role of the invited resource in the invites e2e test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* origin/per-16677/groups-api:
  Poll for the PDP's role assignment list in the RBAC e2e tests

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