Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
43469cc
Add e2e tests for tenant membership and get_user_tenants
zeevmoney Oct 1, 2026
109fe58
Add get_user_tenants to list a user's tenants from the PDP
zeevmoney Oct 1, 2026
2a62214
Add tenants.add_user, which always goes to the Permit API
zeevmoney Oct 1, 2026
18d3e89
Document tenant membership in the README
zeevmoney Oct 1, 2026
b079a67
Merge the tenant membership SDK branch into the tenant membership PR
zeevmoney Oct 1, 2026
e6e939c
Merge the tenant membership e2e branch into the tenant membership PR
zeevmoney Oct 1, 2026
762057f
Share the blocking quiet-delete helper in tests/utils
zeevmoney Oct 1, 2026
084a097
Test that the cloud PDP answers get_user_tenants with a 404
zeevmoney Oct 1, 2026
a3b96e4
Say in CONTRIBUTING that get_user_tenants e2e tests need a container
zeevmoney Oct 1, 2026
d313f4d
Share the cloud PDP address between conftest and the e2e tests
zeevmoney Oct 1, 2026
40fc3f7
Say what delete_tenant_user does to members with no role
zeevmoney Oct 1, 2026
105e17e
Test delete_tenant_user on a member with no role as the API answers
zeevmoney Oct 1, 2026
2edd7ef
List the inline role assignment errors in the add_user docs
zeevmoney Oct 1, 2026
2f274cf
Say in the README that add_user grants each role in its own tenant
zeevmoney Oct 1, 2026
d50dbae
Drop a TenantDetails test that cannot fail
zeevmoney Oct 1, 2026
96a3c2d
Test that get_user_tenants errors do not hold an echoed API key
zeevmoney Oct 1, 2026
5323726
Rename tenants.add_user to create_user and deprecate add_user
zeevmoney Oct 1, 2026
1f9575e
Merge the invites e2e fix from the base branch
zeevmoney Oct 1, 2026
9c45010
Merge the RBAC e2e polling fix from the base branch
zeevmoney Oct 1, 2026
fc62e23
Merge the base branch's RBAC e2e polling fix
zeevmoney Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,13 @@ jobs:
# - latest PDP image: the suite against permitio/pdp-v2:latest, on pydantic 2.
# Red here with `pytest` green means the newest PDP release behaves unlike
# PINNED_PDP_IMAGE.
# - cloud PDP: tests/test_cloud_pdp_e2e.py against the hosted cloud PDP. Each
# test builds a small RBAC policy in the scratch environment, waits for the
# cloud PDP to apply it, and asserts the exact answers of check, bulk_check,
# get_user_permissions and filter_objects. The module skips wherever PDP_URL
# points at a PDP container (see that module).
# - cloud PDP: tests/test_cloud_pdp_e2e.py against the hosted cloud PDP. Its
# tests build a small RBAC policy in the scratch environment, wait for the
# cloud PDP to apply it, and assert the exact answers of check, bulk_check,
# get_user_permissions and filter_objects. One more checks that
# get_user_tenants, which the cloud PDP does not serve, raises the SDK's
# error for its 404. The module skips wherever PDP_URL points at a PDP
# container (see that module).
# Each leg makes its own scratch environment, keyed by run, attempt and leg,
# so it never shares one with a `pytest` lane, the other leg or a re-run.
e2e-unpinned-pdp:
Expand Down
15 changes: 9 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,18 +146,21 @@ passed or not:
differently from the pinned one.
- `e2e (cloud PDP)` is not a required check. Once both `pytest` jobs pass, it runs
`tests/test_cloud_pdp_e2e.py` against the hosted cloud PDP,
`https://cloudpdp.api.permit.io`, with no container. Each test creates its own small RBAC
policy in the scratch environment, waits for the cloud PDP to apply it, and checks the
exact answers of `check`, `bulk_check`, `get_user_permissions` and `filter_objects`. The
module runs only against the cloud PDP and skips anywhere else, so this job fails if any
of its tests is skipped.
`https://cloudpdp.api.permit.io`, with no container. Its tests create a small RBAC policy
in the scratch environment, wait for the cloud PDP to apply it, and check the exact
answers of `check`, `bulk_check`, `get_user_permissions` and `filter_objects`. One more
checks that `get_user_tenants`, which the cloud PDP does not serve, raises the SDK's
error for its 404. The module runs only against the cloud PDP and skips anywhere else,
so this job fails if any of its tests is skipped.

The jobs set:

- `PDP_API_KEY`: the scratch environment's API key. Every e2e test fails without it.
- `PDP_URL`: `http://localhost:7766`, the PDP container, or `https://cloudpdp.api.permit.io`
in `e2e (cloud PDP)`. When it is unset, `tests/test_cloud_pdp_e2e.py` uses the cloud PDP
and every other test `http://localhost:7766`.
and every other test `http://localhost:7766`. The `get_user_tenants` tests in
`tests/test_tenant_membership_e2e.py` need a PDP container, because the cloud PDP does not
serve that query, so they skip, with the reason, when `PDP_URL` is the cloud PDP.
- `API_TIER=prod`: sends the SDK's API calls to `https://api.permit.io`.
- `ORG_PDP_API_KEY` and `PROJECT_PDP_API_KEY`: the same key, read by
`tests/endpoints/test_envs.py`.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ await permit.check("alice", "edit", {"type": "document", "key": "readme", "tenan
- The other methods are `list()`, `get()`, `delete()`, `remove_user()` and `remove_role()`.
The blocking client, `permit.sync.Permit`, has the same methods.

## Tenant membership

`permit.api.tenants.create_user("acme", {"key": "alice"})` creates the user as a member of the
`acme` tenant, with no role there. Any `role_assignments` in the user data are granted as
`permit.api.users.create()` grants them, each in the tenant it names. It fails with
`PermitAlreadyExistsError` (409) when a user with that key already exists, so give an existing
user a role in the tenant with `permit.api.users.assign_role()` instead. The request always
goes to the Permit REST API, even with `proxy_facts_via_pdp`, and needs an environment-level
API key, or a broader key with the SDK's API context set to the environment.
`permit.api.tenants.delete_tenant_user()` answers 404 for a member with no role, so remove
such a member with `permit.api.users.delete()`.

`permit.get_user_tenants("alice")` asks the PDP for the tenants in which the user has a
tenant-level role, as `TenantDetails` objects with a `key` and `attributes`. Membership
without a role, such as `create_user()` creates, is not listed. Only the container PDP serves
this query: the cloud PDP answers 404, which the SDK raises as a `PermitConnectionError`.
Both methods are on the blocking client too.

## Type checking

The package ships a `py.typed` marker (PEP 561), so mypy, pyright and IDEs check your
Expand Down Expand Up @@ -116,6 +134,8 @@ each one issues a `DeprecationWarning` that says what to do instead.
- **The flat methods on `permit.api`**, such as `permit.api.get_user()`. Use the grouped
APIs instead, such as `permit.api.users.get()`. Each flat method's warning names its
replacement.
- **`permit.api.tenants.add_user()`**, an alias of `permit.api.tenants.create_user()`. The
route creates the user, so `create_user()` is the name that says what it does.

By default, Python shows these warnings only when the code that triggers them is in
`__main__`, such as the script you run. pytest shows them in its warnings summary. To see
Expand Down
1 change: 1 addition & 0 deletions permit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from permit.enforcement.interfaces import AssignedRole as AssignedRole
from permit.enforcement.interfaces import AuthorizedUsersResult as AuthorizedUsersResult
from permit.enforcement.interfaces import ResourceInput as ResourceInput
from permit.enforcement.interfaces import TenantDetails as TenantDetails
from permit.enforcement.interfaces import UserInput as UserInput
from permit.exceptions import PermitAlreadyExistsError as PermitAlreadyExistsError
from permit.exceptions import PermitApiDetailedError as PermitApiDetailedError
Expand Down
93 changes: 88 additions & 5 deletions permit/_sync_types.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ from permit.api.models import (
from permit.api.users import _UserSyncInput
from permit.config import PermitConfig
from permit.enforcement.enforcer import Action, CheckQuery, Resource, User
from permit.enforcement.interfaces import AuthorizedUsersResult
from permit.enforcement.interfaces import AuthorizedUsersResult, TenantDetails
from permit.pdp_api.base import BasePdpPermitApi
from permit.pdp_api.models import RoleAssignment
from permit.utils.context import Context, ContextStore
Expand Down Expand Up @@ -2219,6 +2219,53 @@ class SyncTenantsApi(BasePermitApi):
PermitContextError: If the configured ApiContext does not match the required endpoint
context.
"""
def create_user(self, tenant_key: str, user_data: ModelInput[UserCreate]) -> UserRead:
"""Creates a user as a member of a tenant.

The API creates the user and adds it to the tenant without any role. It answers 409
when a user with that key already exists, whichever tenants it is in, so this cannot
add an existing user to another tenant: grant that user a role in the tenant with
``api.users.assign_role()`` instead. Role assignments listed in ``user_data`` are
granted as ``api.users.create()`` grants them, each in the tenant it names.

The request always goes to the Permit REST API, even with ``proxy_facts_via_pdp``
set, so ``wait_for_sync()`` does not make it wait for the PDP. A membership without a
role does not show in ``permit.get_user_tenants()``, which lists the tenants in which
the user has a role, and ``delete_tenant_user()`` cannot remove it: delete the user
with ``api.users.delete()`` instead.

Needs an environment-level API key, or a broader key with the SDK's API context set
to the environment.

Args:
tenant_key: The key or id of the tenant.
user_data: The user to create, as a ``UserCreate`` or an equivalent dict.

Returns:
the created user, whose ``associated_tenants`` include the tenant.

Raises:
PermitAlreadyExistsError: If a user with this key already exists, or a role
assignment in ``user_data`` names a tenant other than the one its resource
instance is in.
PermitNotFoundError: If the tenant does not exist, or a role assignment in
``user_data`` names a role, tenant or resource that does not exist.
PermitApiError: If the API returns any other error HTTP status code.
PermitContextError: If the configured ApiContext does not match the required endpoint
context.
"""
def add_user(self, tenant_key: str, user_data: ModelInput[UserCreate]) -> UserRead:
"""Deprecated: use ``create_user()`` instead, which this calls.

The route creates the user, so it cannot add an existing user to a tenant.

Args:
tenant_key: The key or id of the tenant.
user_data: The user to create, as a ``UserCreate`` or an equivalent dict.

Returns:
the created user, as ``create_user()`` returns it.
"""
def get(self, tenant_key: str) -> TenantRead:
"""Retrieves a tenant by its key.

Expand Down Expand Up @@ -2309,14 +2356,24 @@ class SyncTenantsApi(BasePermitApi):
context.
"""
def delete_tenant_user(self, tenant_key: str, user_key: str) -> None:
"""Deletes a user from a tenant, removing all roles granted to the user in that tenant.
"""Removes the roles a user holds in a tenant.

The API removes the user's tenant-level roles in the tenant, and answers 404 when the
user holds none there. That includes a member that ``create_user()`` created without a
role, which this cannot remove: delete such a user with ``api.users.delete()``.

When the user is then left with no tenant-level role in any tenant, the API deletes
the user, even if the user is still a member of a tenant without a role or holds roles
on resource instances, so ``create_user()`` can create a user with that key again.
Otherwise the user stays a member of the tenant, with no tenant-level role there.

Args:
tenant_key: The key of the tenant from which the user will be deleted.
user_key: The key of the user to be deleted.
tenant_key: The key of the tenant.
user_key: The key of the user whose roles in the tenant to remove.

Raises:
PermitApiError: If the API returns an error HTTP status code.
PermitApiError: If the user holds no tenant-level role in the tenant (404), or the
API returns any other error HTTP status code.
PermitContextError: If the configured ApiContext does not match the required endpoint
context.
"""
Expand Down Expand Up @@ -2770,6 +2827,32 @@ class SyncEnforcer:
Raises:
PermitConnectionError: If the PDP rejects the request or cannot be reached.
"""
def get_user_tenants(self, user: User, context: Context | None = None) -> list[TenantDetails]:
"""Get the tenants in which a user has a role, as the PDP knows them.

The PDP lists a tenant when the user has a tenant-level role in it, the kind
``api.users.assign_role()`` grants. A role on a resource instance does not count, and
neither does membership without a role, such as ``api.tenants.create_user()`` creates.
The PDP answers from the data it has synced, so a change made through the API shows
up once the PDP has it.

Only the container PDP serves this query. The cloud PDP does not, and answers 404,
which this method raises as a ``PermitConnectionError`` that says so.

Args:
user: The user key, or a user dict with a ``key`` and optionally ``attributes``,
``email``, ``first_name`` and ``last_name``, as ``check()`` takes it.
context: The query's context, merged over the context store's base context.
Defaults to None.

Returns:
The user's tenants, each with its key and attributes. Empty when the user has no
tenant-level role or the PDP does not know the user.

Raises:
PermitConnectionError: If the PDP answers 404 (as the cloud PDP does), answers any
other error status, or cannot be reached.
"""
def filter_objects(
self, user: User, action: Action, context: Context, resources: list[dict[str, Any]]
) -> list[dict[str, Any]]:
Expand Down
84 changes: 80 additions & 4 deletions permit/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
TenantDeleteBulkOperationResult,
TenantRead,
TenantUpdate,
UserCreate,
UserRead,
)
from permit.utils.deprecation import deprecated
from permit.utils.model_input import ModelInput, ModelListInput


Expand All @@ -34,6 +37,11 @@ class TenantsApi(BasePermitApi):
def __tenants(self) -> SimpleHttpClient:
if self.config.proxy_facts_via_pdp:
return self._build_http_client("/facts/tenants", use_pdp=True)
return self.__api_tenants

@property
def __api_tenants(self) -> SimpleHttpClient:
"""The tenants collection on the Permit REST API, whatever proxy_facts_via_pdp says."""
return self._build_http_client(
f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/tenants"
)
Expand Down Expand Up @@ -95,6 +103,64 @@ async def list_tenant_users(
params=pagination_params(page, per_page),
)

@validate_arguments
async def create_user(self, tenant_key: str, user_data: ModelInput[UserCreate]) -> UserRead:
"""Creates a user as a member of a tenant.

The API creates the user and adds it to the tenant without any role. It answers 409
when a user with that key already exists, whichever tenants it is in, so this cannot
add an existing user to another tenant: grant that user a role in the tenant with
``api.users.assign_role()`` instead. Role assignments listed in ``user_data`` are
granted as ``api.users.create()`` grants them, each in the tenant it names.

The request always goes to the Permit REST API, even with ``proxy_facts_via_pdp``
set, so ``wait_for_sync()`` does not make it wait for the PDP. A membership without a
role does not show in ``permit.get_user_tenants()``, which lists the tenants in which
the user has a role, and ``delete_tenant_user()`` cannot remove it: delete the user
with ``api.users.delete()`` instead.

Needs an environment-level API key, or a broader key with the SDK's API context set
to the environment.

Args:
tenant_key: The key or id of the tenant.
user_data: The user to create, as a ``UserCreate`` or an equivalent dict.

Returns:
the created user, whose ``associated_tenants`` include the tenant.

Raises:
PermitAlreadyExistsError: If a user with this key already exists, or a role
assignment in ``user_data`` names a tenant other than the one its resource
instance is in.
PermitNotFoundError: If the tenant does not exist, or a role assignment in
``user_data`` names a role, tenant or resource that does not exist.
PermitApiError: If the API returns any other error HTTP status code.
PermitContextError: If the configured ApiContext does not match the required endpoint
context.
"""
await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY)
await self._ensure_context(ApiContextLevel.ENVIRONMENT)
return await self.__api_tenants.post(f"/{tenant_key}/users", model=UserRead, json=user_data)

@deprecated(
"permit.api.tenants.add_user() is deprecated and will be removed in permit 4.0; "
"use permit.api.tenants.create_user() instead."
)
async def add_user(self, tenant_key: str, user_data: ModelInput[UserCreate]) -> UserRead:
"""Deprecated: use ``create_user()`` instead, which this calls.

The route creates the user, so it cannot add an existing user to a tenant.

Args:
tenant_key: The key or id of the tenant.
user_data: The user to create, as a ``UserCreate`` or an equivalent dict.

Returns:
the created user, as ``create_user()`` returns it.
"""
return await self.create_user(tenant_key, user_data)

async def _get(self, tenant_key: str) -> TenantRead:
return await self.__tenants.get(f"/{tenant_key}", model=TenantRead)

Expand Down Expand Up @@ -219,14 +285,24 @@ async def delete(self, tenant_key: str) -> None:

@validate_arguments
async def delete_tenant_user(self, tenant_key: str, user_key: str) -> None:
"""Deletes a user from a tenant, removing all roles granted to the user in that tenant.
"""Removes the roles a user holds in a tenant.

The API removes the user's tenant-level roles in the tenant, and answers 404 when the
user holds none there. That includes a member that ``create_user()`` created without a
role, which this cannot remove: delete such a user with ``api.users.delete()``.

When the user is then left with no tenant-level role in any tenant, the API deletes
the user, even if the user is still a member of a tenant without a role or holds roles
on resource instances, so ``create_user()`` can create a user with that key again.
Otherwise the user stays a member of the tenant, with no tenant-level role there.

Args:
tenant_key: The key of the tenant from which the user will be deleted.
user_key: The key of the user to be deleted.
tenant_key: The key of the tenant.
user_key: The key of the user whose roles in the tenant to remove.

Raises:
PermitApiError: If the API returns an error HTTP status code.
PermitApiError: If the user holds no tenant-level role in the tenant (404), or the
API returns any other error HTTP status code.
PermitContextError: If the configured ApiContext does not match the required endpoint
context.
"""
Expand Down
Loading
Loading