From f52d913777766cf9cc4acb20e5a0e3a60c67d7ab Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 14:42:37 +0300 Subject: [PATCH 01/12] Add the Groups API to the async and sync clients permit.api.groups covers the ten GA group operations (PER-16677): list and get through the /groups/direct reads, create, delete, adding and removing users, granting and revoking roles, and making one group's members members of another. Model arguments take the model or a dict. The docstrings state which identifier forms each call accepts, the API key it needs, the direction of assign_group, and that a group's roles reach its members through ReBAC role derivation. The sync stub is regenerated, the sub-API count sentinel goes to 18, and the type-check consumer calls the new API on both clients. Co-Authored-By: Claude Opus 5.5 --- permit/_sync_types.pyi | 282 +++++++++++++++++++++++++ permit/api/api_client.py | 10 + permit/api/groups.py | 374 ++++++++++++++++++++++++++++++++++ permit/api/sync_api_client.py | 14 ++ tests/test_fix_sync_parity.py | 2 +- tests/type_check/consumer.py | 19 ++ 6 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 permit/api/groups.py diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 558bcc48..379bb252 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -28,7 +28,13 @@ from permit.api.models import ( EnvironmentRead, EnvironmentStats, EnvironmentUpdate, + GroupAddRole, + GroupAssignment, + GroupCreate, + GroupRead, + GroupReadSchema, PaginatedResultElementsUserInviteRead, + PaginatedResultGroupReadSchema, PaginatedResultRelationRead, PaginatedResultUserRead, PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings, @@ -493,6 +499,282 @@ class SyncEnvironmentsApi(BasePermitApi): context. """ +class SyncGroupsApi(BasePermitApi): + """Manage groups, whose members inherit the roles granted to the group. + + A group is an instance of a group resource type (``group`` unless you name another) in + one tenant. A user added to a group gets the ``member`` role on the group instance. A + role granted to a group is a resource role on one resource instance, and it reaches the + group's members through ReBAC: a role derivation grants it to every user who has the + ``member`` role on the group instance. ``permit.check()`` then allows a member what that + role allows on that instance. It is not a tenant-wide (RBAC) role: a check must name + the resource instance, and a check that names only the resource type does not use it. + + Every method needs an environment-level API key, or a project- or organization-level + key with the SDK's API context set to the environment. + + The ``group_instance_key`` argument of every method but ``list()`` and ``create()`` + accepts any of: + + - the group instance's id, the ``id`` that ``get()`` and ``list()`` return; + - ``":"``, the group's resource type key and instance key, such as + ``"group:engineering"`` or ``"team:engineering"``; + - the instance key alone, such as ``"engineering"``. The API reads it as + ``"group:engineering"``, so it finds only groups of the ``group`` resource type. Name + a group of any other resource type by its id or by the ``":"`` form. + """ + def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultGroupReadSchema: + """Lists the environment's groups, of every group resource type. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many groups to fetch per page, at most 100 (default: 100). + + Returns: + One page of groups, with the total count. Each group's ``group_instance_key`` is + its instance key alone, and ``id`` is its instance id. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def get(self, group_instance_key: str) -> GroupReadSchema: + """Retrieves a group. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + + Returns: + The group. Its ``group_instance_key`` is its instance key alone, and ``id`` is + its instance id. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when no + such group exists. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def create(self, group_data: ModelInput[GroupCreate]) -> GroupRead: + """Creates a group. + + The group is a new instance of the resource type ``group_data.group_resource_type_key`` + (``group`` if not set) in the tenant ``group_data.group_tenant``. The API creates that + resource type if it does not exist, and adds to it the ``member`` role that group + membership uses if it has none. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_data: The group to create. Its ``group_instance_key`` is the new instance's + key alone, such as ``"engineering"``, not ``"group:engineering"``; its + ``group_tenant`` is the key or id of the tenant the group belongs to. + + Returns: + The created group. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 409 when the + group already exists. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def delete(self, group_instance_key: str) -> None: + """Deletes a group: the group's resource instance. + + When no instance of the group's resource type remains, the API also deletes that + resource type's ``member`` role. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when no + such group exists. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def assign_user(self, group_instance_key: str, user_key: str, tenant: str) -> GroupRead: + """Adds a user to a group. + + The user gets the ``member`` role on the group instance, in the group's tenant. + Through it, the user gets the roles granted to the group with ``assign_role()``, and + those of each group ``other`` after + ``assign_group(, {"group_instance_key": other})``. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + user_key: The key or id of the user to add. + tenant: The key of the group's tenant. The API requires it, and the membership + always applies in the tenant the group belongs to. + + Returns: + The group, with the ids of the users added to it and its assigned roles. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group or the user does not exist. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def remove_user(self, group_instance_key: str, user_key: str, tenant: str) -> None: + """Removes a user from a group. + + The user loses the ``member`` role on the group instance, and with it the roles the + group passed on, unless the user holds them some other way. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + user_key: The key or id of the user to remove. + tenant: The key of the group's tenant. The API requires it, and the membership + always applies in the tenant the group belongs to. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group or the user does not exist. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def assign_role( + self, group_instance_key: str, role_data: ModelInput[GroupAddRole] + ) -> GroupRead: + """Grants a group a resource role on one resource instance. + + Every member of the group gets the role on that instance through ReBAC: the API links + the group instance to the resource instance and derives the role from the group's + ``member`` role. Users who join the group later get it too, and members who leave + lose it. The role applies to that instance only, not tenant-wide. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + role_data: What to grant. ``role`` is the key or id of a role of ``resource``. + ``resource`` is the resource's key and ``resource_instance`` the instance's + key: the API looks up ``":"`` in the group's + tenant and creates the instance there if it does not exist. ``tenant`` is + required by the API: pass the group's tenant. + + Returns: + The group, with the ids of the users added to it and its assigned roles. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group, the resource or the role does not exist. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def remove_role(self, group_instance_key: str, role_data: ModelInput[GroupAddRole]) -> None: + """Revokes a resource role on one resource instance from a group. + + The group's members lose the role on that instance, unless they hold it some other + way. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + role_data: What to revoke, as it was granted with ``assign_role()``. ``role`` and + ``resource`` are keys or ids, ``resource_instance`` is the instance's key or + id. ``tenant`` is required by the API: pass the group's tenant. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group, the role or the resource instance does not exist. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def assign_group( + self, group_instance_key: str, assignment: ModelInput[GroupAssignment] + ) -> GroupRead: + """Makes the members of one group members of another. + + Every member of the group ``group_instance_key`` gets the ``member`` role on the group + named in ``assignment``, and through it the roles granted to that group. It works in + that direction only: the members of the group in ``assignment`` gain nothing from + ``group_instance_key``. For example, after + ``assign_group("group:leads", {"group_instance_key": "engineering"})`` the members of + ``leads`` have the roles granted to ``engineering``. Both groups must be of the same + group resource type. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group whose members join the other group, by instance + id, by ``":"`` such as ``"group:leads"``, or by instance key alone + if it is of the ``group`` resource type. + assignment: The group they join. Its ``group_instance_key`` is that group's + instance id or its instance key alone, such as ``"engineering"``: the + ``":"`` form is not accepted here. + + Returns: + The group ``group_instance_key``. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when + either group does not exist, or 409 when the members of the first group are + already members of the second this way. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def remove_group( + self, group_instance_key: str, assignment: ModelInput[GroupAssignment] + ) -> None: + """Undoes ``assign_group()``: the members of one group stop being members of another. + + The members of the group ``group_instance_key`` lose the ``member`` role on the group + named in ``assignment``, and the roles that came with it, unless they hold them some + other way. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group whose members leave the other group, by instance + id, by ``":"`` such as ``"group:leads"``, or by instance key alone + if it is of the ``group`` resource type. + assignment: The group they leave. Its ``group_instance_key`` is that group's + instance id or its instance key alone, such as ``"engineering"``: the + ``":"`` form is not accepted here. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when + either group does not exist. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + class SyncProjectsApi(BasePermitApi): """Manage the projects of an organization.""" def __init__(self, config: PermitConfig) -> None: ... diff --git a/permit/api/api_client.py b/permit/api/api_client.py index 3152b05c..f3890155 100644 --- a/permit/api/api_client.py +++ b/permit/api/api_client.py @@ -2,6 +2,7 @@ from permit.api.condition_sets import ConditionSetsApi from permit.api.deprecated import DeprecatedApi from permit.api.environments import EnvironmentsApi +from permit.api.groups import GroupsApi from permit.api.projects import ProjectsApi from permit.api.relationship_tuples import RelationshipTuplesApi from permit.api.resource_action_groups import ResourceActionGroupsApi @@ -33,6 +34,7 @@ def __init__(self, config: PermitConfig) -> None: self._condition_set_rules = ConditionSetRulesApi(config) self._condition_sets = ConditionSetsApi(config) self._environments = EnvironmentsApi(config) + self._groups = GroupsApi(config) self._projects = ProjectsApi(config) self._action_groups = ResourceActionGroupsApi(config) self._resource_actions = ResourceActionsApi(config) @@ -80,6 +82,14 @@ def environments(self) -> EnvironmentsApi: """ return self._environments + @property + def groups(self) -> GroupsApi: + """API for managing groups. + + See: https://api.permit.io/v2/redoc#tag/Groups + """ + return self._groups + @property def action_groups(self) -> ResourceActionGroupsApi: """API for managing resource action groups. diff --git a/permit/api/groups.py b/permit/api/groups.py new file mode 100644 index 00000000..b36d2d6d --- /dev/null +++ b/permit/api/groups.py @@ -0,0 +1,374 @@ +from typing import TYPE_CHECKING + +from permit.utils.pydantic_version import PYDANTIC_VERSION + +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): + from pydantic import validate_arguments +else: + from pydantic.v1 import validate_arguments + +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( + GroupAddRole, + GroupAssignment, + GroupAssignUser, + GroupCreate, + GroupRead, + GroupReadSchema, + PaginatedResultGroupReadSchema, +) +from permit.utils.model_input import ModelInput + + +class GroupsApi(BasePermitApi): + """Manage groups, whose members inherit the roles granted to the group. + + A group is an instance of a group resource type (``group`` unless you name another) in + one tenant. A user added to a group gets the ``member`` role on the group instance. A + role granted to a group is a resource role on one resource instance, and it reaches the + group's members through ReBAC: a role derivation grants it to every user who has the + ``member`` role on the group instance. ``permit.check()`` then allows a member what that + role allows on that instance. It is not a tenant-wide (RBAC) role: a check must name + the resource instance, and a check that names only the resource type does not use it. + + Every method needs an environment-level API key, or a project- or organization-level + key with the SDK's API context set to the environment. + + The ``group_instance_key`` argument of every method but ``list()`` and ``create()`` + accepts any of: + + - the group instance's id, the ``id`` that ``get()`` and ``list()`` return; + - ``":"``, the group's resource type key and instance key, such as + ``"group:engineering"`` or ``"team:engineering"``; + - the instance key alone, such as ``"engineering"``. The API reads it as + ``"group:engineering"``, so it finds only groups of the ``group`` resource type. Name + a group of any other resource type by its id or by the ``":"`` form. + """ + + @property + def __groups(self) -> SimpleHttpClient: + return self._build_http_client( + f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/groups" + ) + + @validate_arguments + async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultGroupReadSchema: + """Lists the environment's groups, of every group resource type. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many groups to fetch per page, at most 100 (default: 100). + + Returns: + One page of groups, with the total count. Each group's ``group_instance_key`` is + its instance key alone, and ``id`` is its instance id. + + Raises: + PermitApiError: If the API returns an 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.__groups.get( + "/direct", + model=PaginatedResultGroupReadSchema, + params=pagination_params(page, per_page), + ) + + @validate_arguments + async def get(self, group_instance_key: str) -> GroupReadSchema: + """Retrieves a group. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + + Returns: + The group. Its ``group_instance_key`` is its instance key alone, and ``id`` is + its instance id. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when no + such group exists. + 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.__groups.get(f"/direct/{group_instance_key}", model=GroupReadSchema) + + @validate_arguments + async def create(self, group_data: ModelInput[GroupCreate]) -> GroupRead: + """Creates a group. + + The group is a new instance of the resource type ``group_data.group_resource_type_key`` + (``group`` if not set) in the tenant ``group_data.group_tenant``. The API creates that + resource type if it does not exist, and adds to it the ``member`` role that group + membership uses if it has none. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_data: The group to create. Its ``group_instance_key`` is the new instance's + key alone, such as ``"engineering"``, not ``"group:engineering"``; its + ``group_tenant`` is the key or id of the tenant the group belongs to. + + Returns: + The created group. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 409 when the + group already exists. + 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.__groups.post("", model=GroupRead, json=group_data) + + @validate_arguments + async def delete(self, group_instance_key: str) -> None: + """Deletes a group: the group's resource instance. + + When no instance of the group's resource type remains, the API also deletes that + resource type's ``member`` role. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when no + such group exists. + 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) + await self.__groups.delete(f"/{group_instance_key}") + + @validate_arguments + async def assign_user(self, group_instance_key: str, user_key: str, tenant: str) -> GroupRead: + """Adds a user to a group. + + The user gets the ``member`` role on the group instance, in the group's tenant. + Through it, the user gets the roles granted to the group with ``assign_role()``, and + those of each group ``other`` after + ``assign_group(, {"group_instance_key": other})``. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + user_key: The key or id of the user to add. + tenant: The key of the group's tenant. The API requires it, and the membership + always applies in the tenant the group belongs to. + + Returns: + The group, with the ids of the users added to it and its assigned roles. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group or the user does not exist. + 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.__groups.put( + f"/{group_instance_key}/users/{user_key}", + model=GroupRead, + json=GroupAssignUser(tenant=tenant), + ) + + @validate_arguments + async def remove_user(self, group_instance_key: str, user_key: str, tenant: str) -> None: + """Removes a user from a group. + + The user loses the ``member`` role on the group instance, and with it the roles the + group passed on, unless the user holds them some other way. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + user_key: The key or id of the user to remove. + tenant: The key of the group's tenant. The API requires it, and the membership + always applies in the tenant the group belongs to. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group or the user does not exist. + 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) + await self.__groups.delete( + f"/{group_instance_key}/users/{user_key}", + json=GroupAssignUser(tenant=tenant), + ) + + @validate_arguments + async def assign_role( + self, group_instance_key: str, role_data: ModelInput[GroupAddRole] + ) -> GroupRead: + """Grants a group a resource role on one resource instance. + + Every member of the group gets the role on that instance through ReBAC: the API links + the group instance to the resource instance and derives the role from the group's + ``member`` role. Users who join the group later get it too, and members who leave + lose it. The role applies to that instance only, not tenant-wide. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + role_data: What to grant. ``role`` is the key or id of a role of ``resource``. + ``resource`` is the resource's key and ``resource_instance`` the instance's + key: the API looks up ``":"`` in the group's + tenant and creates the instance there if it does not exist. ``tenant`` is + required by the API: pass the group's tenant. + + Returns: + The group, with the ids of the users added to it and its assigned roles. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group, the resource or the role does not exist. + 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.__groups.post( + f"/{group_instance_key}/roles", model=GroupRead, json=role_data + ) + + @validate_arguments + async def remove_role( + self, group_instance_key: str, role_data: ModelInput[GroupAddRole] + ) -> None: + """Revokes a resource role on one resource instance from a group. + + The group's members lose the role on that instance, unless they hold it some other + way. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group, by instance id, by ``":"`` such as + ``"group:engineering"``, or by instance key alone if it is of the ``group`` + resource type. + role_data: What to revoke, as it was granted with ``assign_role()``. ``role`` and + ``resource`` are keys or ids, ``resource_instance`` is the instance's key or + id. ``tenant`` is required by the API: pass the group's tenant. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when the + group, the role or the resource instance does not exist. + 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) + await self.__groups.delete(f"/{group_instance_key}/roles", json=role_data) + + @validate_arguments + async def assign_group( + self, group_instance_key: str, assignment: ModelInput[GroupAssignment] + ) -> GroupRead: + """Makes the members of one group members of another. + + Every member of the group ``group_instance_key`` gets the ``member`` role on the group + named in ``assignment``, and through it the roles granted to that group. It works in + that direction only: the members of the group in ``assignment`` gain nothing from + ``group_instance_key``. For example, after + ``assign_group("group:leads", {"group_instance_key": "engineering"})`` the members of + ``leads`` have the roles granted to ``engineering``. Both groups must be of the same + group resource type. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group whose members join the other group, by instance + id, by ``":"`` such as ``"group:leads"``, or by instance key alone + if it is of the ``group`` resource type. + assignment: The group they join. Its ``group_instance_key`` is that group's + instance id or its instance key alone, such as ``"engineering"``: the + ``":"`` form is not accepted here. + + Returns: + The group ``group_instance_key``. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when + either group does not exist, or 409 when the members of the first group are + already members of the second this way. + 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.__groups.put( + f"/{group_instance_key}/assign_group", model=GroupRead, json=assignment + ) + + @validate_arguments + async def remove_group( + self, group_instance_key: str, assignment: ModelInput[GroupAssignment] + ) -> None: + """Undoes ``assign_group()``: the members of one group stop being members of another. + + The members of the group ``group_instance_key`` lose the ``member`` role on the group + named in ``assignment``, and the roles that came with it, unless they hold them some + other way. + + Needs an environment-level API key, or a broader key with the SDK's API context + set to the environment. + + Args: + group_instance_key: The group whose members leave the other group, by instance + id, by ``":"`` such as ``"group:leads"``, or by instance key alone + if it is of the ``group`` resource type. + assignment: The group they leave. Its ``group_instance_key`` is that group's + instance id or its instance key alone, such as ``"engineering"``: the + ``":"`` form is not accepted here. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 404 when + either group does not exist. + 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) + await self.__groups.delete(f"/{group_instance_key}/assign_group", json=assignment) diff --git a/permit/api/sync_api_client.py b/permit/api/sync_api_client.py index 5f712061..2a4f5566 100644 --- a/permit/api/sync_api_client.py +++ b/permit/api/sync_api_client.py @@ -4,6 +4,7 @@ from permit.api.condition_sets import ConditionSetsApi from permit.api.deprecated import DeprecatedApi from permit.api.environments import EnvironmentsApi +from permit.api.groups import GroupsApi from permit.api.projects import ProjectsApi from permit.api.relationship_tuples import RelationshipTuplesApi from permit.api.resource_action_groups import ResourceActionGroupsApi @@ -28,6 +29,7 @@ from permit._sync_types import SyncConditionSetsApi as SyncConditionSetsApi from permit._sync_types import SyncDeprecatedApi as SyncDeprecatedApi from permit._sync_types import SyncEnvironmentsApi as SyncEnvironmentsApi + from permit._sync_types import SyncGroupsApi as SyncGroupsApi from permit._sync_types import SyncProjectsApi as SyncProjectsApi from permit._sync_types import SyncRelationshipTuplesApi as SyncRelationshipTuplesApi from permit._sync_types import SyncResourceActionGroupsApi as SyncResourceActionGroupsApi @@ -56,6 +58,9 @@ class SyncDeprecatedApi(DeprecatedApi, metaclass=SyncClass): class SyncEnvironmentsApi(EnvironmentsApi, metaclass=SyncClass): """Blocking variant of `EnvironmentsApi`.""" + class SyncGroupsApi(GroupsApi, metaclass=SyncClass): + """Blocking variant of `GroupsApi`.""" + class SyncProjectsApi(ProjectsApi, metaclass=SyncClass): """Blocking variant of `ProjectsApi`.""" @@ -113,6 +118,7 @@ def __init__(self, config: PermitConfig) -> None: self._condition_set_rules = SyncConditionSetRulesApi(config) self._condition_sets = SyncConditionSetsApi(config) self._environments = SyncEnvironmentsApi(config) + self._groups = SyncGroupsApi(config) self._projects = SyncProjectsApi(config) self._relationship_tuples = SyncRelationshipTuplesApi(config) self._action_groups = SyncResourceActionGroupsApi(config) @@ -160,6 +166,14 @@ def environments(self) -> SyncEnvironmentsApi: """ return self._environments + @property + def groups(self) -> SyncGroupsApi: + """API for managing groups. + + See: https://api.permit.io/v2/redoc#tag/Groups + """ + return self._groups + @property def action_groups(self) -> SyncResourceActionGroupsApi: """API for managing resource action groups. diff --git a/tests/test_fix_sync_parity.py b/tests/test_fix_sync_parity.py index 16bd195d..21301a1f 100644 --- a/tests/test_fix_sync_parity.py +++ b/tests/test_fix_sync_parity.py @@ -31,7 +31,7 @@ # PermitApiClient has this many sub-API properties. The walk descends only through # properties, so if it finds fewer it has stopped seeing them, and the parity checks # pass without having looked. Lower it only when a sub-API is removed. -API_SUB_API_COUNT = 17 +API_SUB_API_COUNT = 18 # The walk only reads attributes, so nothing is ever sent here. NO_SERVER = "http://localhost:1" diff --git a/tests/type_check/consumer.py b/tests/type_check/consumer.py index 984c5b69..3413fcff 100644 --- a/tests/type_check/consumer.py +++ b/tests/type_check/consumer.py @@ -15,6 +15,11 @@ from permit.api.elements import UserLoginAsResponse from permit.api.models import ( BulkRoleAssignmentReport, + GroupAddRole, + GroupCreate, + GroupRead, + GroupReadSchema, + PaginatedResultGroupReadSchema, PaginatedResultUserRead, RoleAssignmentCreate, RoleAssignmentRead, @@ -101,6 +106,14 @@ async def async_client() -> None: BulkRoleAssignmentReport, ) await permit.api.users.sync({"key": "u", "email": "u@example.com"}) + assert_type( + await permit.api.groups.create({"group_instance_key": "eng", "group_tenant": "t1"}), + GroupRead, + ) + assert_type(await permit.api.groups.get("group:eng"), GroupReadSchema) + group_role = GroupAddRole(role="editor", resource="doc", resource_instance="d1", tenant="t1") + assert_type(await permit.api.groups.assign_role("eng", group_role), GroupRead) + await permit.api.groups.remove_role("eng", group_role) # A list built before a bulk call is accepted too, whether of models or of dicts. users = [UserCreate(key=key) for key in ("u4", "u5")] @@ -137,6 +150,7 @@ def dict_parameters(query: CheckQuery) -> None: assert_type(parameter_type(permit.api.users.sync), UserCreate | dict[str, Any]) assert_type(parameter_type(sync_permit.api.users.sync), UserCreate | dict[str, Any]) assert_type(parameter_type(sync_permit.api.create_tenant), TenantCreate | dict[str, Any]) + assert_type(parameter_type(sync_permit.api.groups.create), GroupCreate | dict[str, Any]) def sync_client() -> None: @@ -154,6 +168,11 @@ def sync_client() -> None: users: list[UserCreate] = [UserCreate(key="u5")] permit.api.users.bulk_replace(users) assert_type(permit.api.get_user("u"), UserRead) + assert_type(permit.api.groups.list(), PaginatedResultGroupReadSchema) + assert_type(permit.api.groups.assign_user("eng", "u", "t1"), GroupRead) + assert_type( + permit.api.groups.assign_group("group:leads", {"group_instance_key": "eng"}), GroupRead + ) assert_type(permit.elements.login_as("u", "t1"), UserLoginAsResponse) pdp_role_assignments: SyncRoleAssignmentsApi = permit.pdp_api.role_assignments assert_type(pdp_role_assignments.list(), list[RoleAssignment]) From 90341bb6436c282aeaa498e71242afecd16ab944 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 14:42:44 +0300 Subject: [PATCH 02/12] Test the Groups API requests offline on both clients Each of the ten methods is called through the async and the blocking client against pytest-httpserver. The tests check the method, path, query, headers and JSON body sent, the model the response parses into, the identifier forms passed through the path, model and dict arguments sending the same body, 404 and 409 raising PermitApiError with that status, and an invalid dict being rejected before anything is sent. Co-Authored-By: Claude Opus 5.5 --- tests/test_groups_offline.py | 364 +++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 tests/test_groups_offline.py diff --git a/tests/test_groups_offline.py b/tests/test_groups_offline.py new file mode 100644 index 00000000..b5247b92 --- /dev/null +++ b/tests/test_groups_offline.py @@ -0,0 +1,364 @@ +"""Offline tests for permit.api.groups (PER-16677). + +Every public method is called through the async and the blocking client, and the test +checks the request it puts on the wire (method, path, query string, headers and JSON +body) and the model the response parses into. Every request is served by a local +``pytest_httpserver`` and the API context is pre-populated, so no API key and no +``/v2/api-key/scope`` lookup are needed. +""" + +import asyncio +import inspect +from operator import attrgetter +from typing import Any, NamedTuple + +import pytest +from pydantic.v1 import BaseModel, ValidationError +from pytest_httpserver import HTTPServer +from werkzeug import Request + +from permit import Permit +from permit.api.groups import GroupsApi +from permit.api.models import ( + GroupAddRole, + GroupAssignment, + GroupCreate, + GroupRead, + GroupReadSchema, + PaginatedResultGroupReadSchema, +) +from permit.config import PermitConfig +from permit.exceptions import PermitApiError +from permit.sync import Permit as SyncPermit +from tests.utils import SCHEMA, Call, call, sent + +GROUPS = f"{SCHEMA}/groups" +GROUP_ID = "00000000-0000-4000-8000-000000000010" +USER_ID = "00000000-0000-4000-8000-000000000011" +DEFAULT_PAGE = [("page", "1"), ("per_page", "100")] +SECOND_PAGE = [("page", "2"), ("per_page", "10")] + +GROUP_SCHEMA = { + "id": GROUP_ID, + "group_resource_type_key": "group", + "group_instance_key": "engineering", + "group_tenant": "default", +} +GROUP_READ = { + "group_resource_type_key": "group", + "group_instance_key": "engineering", + "group_tenant": "default", + "assigned_roles": ["document:readme#editor", "group:engineering#member"], + "users": [USER_ID], +} +GROUP_PAGE = {"data": [GROUP_SCHEMA], "total_count": 1, "page_count": 1} + +NEW_GROUP = {"group_instance_key": "engineering", "group_tenant": "default"} +TEAM_GROUP = { + "group_resource_type_key": "team", + "group_instance_key": "engineering", + "group_tenant": "default", +} +ROLE = { + "role": "editor", + "resource": "document", + "resource_instance": "readme", + "tenant": "default", +} +MEMBER_GROUP = {"group_instance_key": "leads"} + + +class Case(NamedTuple): + """One SDK call and the request it must send. + + ``response`` is the JSON the server answers with, or None for an empty 204; + ``model`` is what it parses into, or None when the method returns nothing. + """ + + call: Call + method: str + path: str + query: list[tuple[str, str]] + body: Any + response: dict[str, Any] | None + model: type[BaseModel] | None + + +CASES = { + "list": Case( + call=call("list"), + method="GET", + path=f"{GROUPS}/direct", + query=DEFAULT_PAGE, + body=None, + response=GROUP_PAGE, + model=PaginatedResultGroupReadSchema, + ), + "list-page": Case( + call=call("list", page=2, per_page=10), + method="GET", + path=f"{GROUPS}/direct", + query=SECOND_PAGE, + body=None, + response=GROUP_PAGE, + model=PaginatedResultGroupReadSchema, + ), + "get": Case( + call=call("get", "engineering"), + method="GET", + path=f"{GROUPS}/direct/engineering", + query=[], + body=None, + response=GROUP_SCHEMA, + model=GroupReadSchema, + ), + "get-by-type-and-key": Case( + call=call("get", "team:engineering"), + method="GET", + path=f"{GROUPS}/direct/team:engineering", + query=[], + body=None, + response=GROUP_SCHEMA, + model=GroupReadSchema, + ), + "get-by-id": Case( + call=call("get", GROUP_ID), + method="GET", + path=f"{GROUPS}/direct/{GROUP_ID}", + query=[], + body=None, + response=GROUP_SCHEMA, + model=GroupReadSchema, + ), + "create": Case( + call=call("create", GroupCreate(**NEW_GROUP)), + method="POST", + path=GROUPS, + query=[], + body=NEW_GROUP, + response=GROUP_READ, + model=GroupRead, + ), + "create-dict": Case( + call=call("create", NEW_GROUP), + method="POST", + path=GROUPS, + query=[], + body=NEW_GROUP, + response=GROUP_READ, + model=GroupRead, + ), + "create-other-resource-type": Case( + call=call("create", TEAM_GROUP), + method="POST", + path=GROUPS, + query=[], + body=TEAM_GROUP, + response={**GROUP_READ, "group_resource_type_key": "team"}, + model=GroupRead, + ), + "delete": Case( + call=call("delete", "group:engineering"), + method="DELETE", + path=f"{GROUPS}/group:engineering", + query=[], + body=None, + response=None, + model=None, + ), + "assign_user": Case( + call=call("assign_user", "engineering", "alice", "default"), + method="PUT", + path=f"{GROUPS}/engineering/users/alice", + query=[], + body={"tenant": "default"}, + response=GROUP_READ, + model=GroupRead, + ), + "assign_user-keywords": Case( + call=call("assign_user", group_instance_key=GROUP_ID, user_key=USER_ID, tenant="t-2"), + method="PUT", + path=f"{GROUPS}/{GROUP_ID}/users/{USER_ID}", + query=[], + body={"tenant": "t-2"}, + response=GROUP_READ, + model=GroupRead, + ), + "remove_user": Case( + call=call("remove_user", "group:engineering", "alice", "default"), + method="DELETE", + path=f"{GROUPS}/group:engineering/users/alice", + query=[], + body={"tenant": "default"}, + response=None, + model=None, + ), + "assign_role": Case( + call=call("assign_role", "engineering", GroupAddRole(**ROLE)), + method="POST", + path=f"{GROUPS}/engineering/roles", + query=[], + body=ROLE, + response=GROUP_READ, + model=GroupRead, + ), + "assign_role-dict": Case( + call=call("assign_role", "engineering", ROLE), + method="POST", + path=f"{GROUPS}/engineering/roles", + query=[], + body=ROLE, + response=GROUP_READ, + model=GroupRead, + ), + "remove_role": Case( + call=call("remove_role", "engineering", GroupAddRole(**ROLE)), + method="DELETE", + path=f"{GROUPS}/engineering/roles", + query=[], + body=ROLE, + response=None, + model=None, + ), + "remove_role-dict": Case( + call=call("remove_role", "engineering", ROLE), + method="DELETE", + path=f"{GROUPS}/engineering/roles", + query=[], + body=ROLE, + response=None, + model=None, + ), + "assign_group": Case( + call=call("assign_group", "engineering", GroupAssignment(**MEMBER_GROUP)), + method="PUT", + path=f"{GROUPS}/engineering/assign_group", + query=[], + body=MEMBER_GROUP, + response=GROUP_READ, + model=GroupRead, + ), + "assign_group-dict": Case( + call=call("assign_group", "engineering", MEMBER_GROUP), + method="PUT", + path=f"{GROUPS}/engineering/assign_group", + query=[], + body=MEMBER_GROUP, + response=GROUP_READ, + model=GroupRead, + ), + "remove_group": Case( + call=call("remove_group", "engineering", GroupAssignment(**MEMBER_GROUP)), + method="DELETE", + path=f"{GROUPS}/engineering/assign_group", + query=[], + body=MEMBER_GROUP, + response=None, + model=None, + ), + "remove_group-dict": Case( + call=call("remove_group", "engineering", MEMBER_GROUP), + method="DELETE", + path=f"{GROUPS}/engineering/assign_group", + query=[], + body=MEMBER_GROUP, + response=None, + model=None, + ), +} + +# The case named after each method, for the error tests. +BASIC_CASES = {name: case for name, case in CASES.items() if name == case.call.path} + +# A model argument given as a dict that misses a required field, for each method that +# takes one. +INVALID_DICTS = { + "create": call("create", {"group_tenant": "default"}), + "assign_role": call("assign_role", "engineering", {"role": "editor", "tenant": "default"}), + "remove_role": call("remove_role", "engineering", {"role": "editor", "tenant": "default"}), + "assign_group": call("assign_group", "engineering", {}), + "remove_group": call("remove_group", "engineering", {}), +} + + +def invoke(config: PermitConfig, flavour: str, target: Call) -> object: + """Call ``permit.api.groups.`` on the async or the blocking client.""" + permit = Permit(config) if flavour == "async" else SyncPermit(config) + method = attrgetter(f"api.groups.{target.path}")(permit) + result = method(*target.args, **target.kwargs) + if flavour == "async": + return asyncio.run(result) + assert not inspect.isawaitable(result) + return result + + +def sent_headers(request: Request) -> dict[str, str | None]: + return {name: request.headers.get(name) for name in ("Authorization", "Content-Type")} + + +def test_every_public_method_has_a_case() -> None: + public = { + name + for name, value in vars(GroupsApi).items() + if not name.startswith("_") and callable(value) + } + + assert {case.call.path for case in CASES.values()} == public + assert set(BASIC_CASES) == public + assert len(public) == 10 + + +@pytest.mark.parametrize("flavour", ["async", "sync"]) +@pytest.mark.parametrize("case", CASES.values(), ids=CASES.keys()) +def test_request_and_response( + httpserver: HTTPServer, config: PermitConfig, case: Case, flavour: str +) -> None: + handler = httpserver.expect_request(case.path, method=case.method) + if case.response is None: + handler.respond_with_data("", status=204) + else: + handler.respond_with_json(case.response) + + result = invoke(config, flavour, case.call) + + assert [sent(request) for request, _ in httpserver.log] == [ + {"method": case.method, "path": case.path, "query": case.query, "body": case.body} + ] + assert [sent_headers(request) for request, _ in httpserver.log] == [ + {"Authorization": "Bearer test-token", "Content-Type": "application/json"} + ] + if case.model is None: + assert result is None + else: + assert type(result) is case.model + assert result == case.model.parse_obj(case.response) + + +@pytest.mark.parametrize("flavour", ["async", "sync"]) +@pytest.mark.parametrize("status", [404, 409]) +@pytest.mark.parametrize("case", BASIC_CASES.values(), ids=BASIC_CASES.keys()) +def test_error_status_raises_permit_api_error( + httpserver: HTTPServer, config: PermitConfig, case: Case, status: int, flavour: str +) -> None: + detail = {"error_code": "ERROR", "message": f"status {status}"} + httpserver.expect_request(case.path, method=case.method).respond_with_json( + detail, status=status + ) + + with pytest.raises(PermitApiError) as raised: + invoke(config, flavour, case.call) + + assert raised.value.status_code == status + assert raised.value.details == detail + assert len(httpserver.log) == 1 + + +@pytest.mark.parametrize("flavour", ["async", "sync"]) +@pytest.mark.parametrize("target", INVALID_DICTS.values(), ids=INVALID_DICTS.keys()) +def test_an_invalid_dict_is_rejected_before_sending( + httpserver: HTTPServer, config: PermitConfig, target: Call, flavour: str +) -> None: + with pytest.raises(ValidationError): + invoke(config, flavour, target) + + assert httpserver.log == [] From b7bf938f298dcb099101574ef8bc04a510ecce75 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 14:42:44 +0300 Subject: [PATCH 03/12] Document the Groups API in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 01e3ed49..d73ef0c0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,35 @@ every breaking change, who it affects and what to change. To have an AI agent su do the upgrade, use the [permit-python-3-migration skill](https://github.com/permitio/permit-python/tree/main/skills/permit-python-3-migration). +## Groups + +`permit.api.groups` manages groups. A group is a resource instance, of the `group` resource +type unless you name another, whose members inherit the roles granted to the group: + +```py +groups = permit.api.groups +await groups.create({"group_instance_key": "engineering", "group_tenant": "default"}) +await groups.assign_user("engineering", "alice", tenant="default") +await groups.assign_role( + "engineering", + {"role": "editor", "resource": "document", "resource_instance": "readme", "tenant": "default"}, +) +# Allowed once the PDP has the change, if the editor role grants "edit" on documents: +await permit.check("alice", "edit", {"type": "document", "key": "readme", "tenant": "default"}) +``` + +- A role granted to a group is a resource role on one resource instance. Members get it + through ReBAC role derivation over the group instance, so `permit.check()` allows it on + that instance. It is not a tenant-wide (RBAC) role. +- A method's `group_instance_key` takes the group's instance id, `":"` such as + `"group:engineering"` or `"team:engineering"`, or the key alone (`"engineering"`), which + finds only groups of the `group` resource type. +- `assign_group("group:leads", {"group_instance_key": "engineering"})` makes the members of + `leads` members of `engineering`, so they get the roles granted to `engineering`. The + members of `engineering` get nothing from `leads`. `remove_group()` undoes it. +- The other methods are `list()`, `get()`, `delete()`, `remove_user()` and `remove_role()`. + The blocking client, `permit.sync.Permit`, has the same methods. + ## Type checking The package ships a `py.typed` marker (PEP 561), so mypy, pyright and IDEs check your From d7a9860f9a12411549bc79b10529ba316edbb7ea Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 14:45:04 +0300 Subject: [PATCH 04/12] Add end-to-end tests for the Groups API Cover the ten GA group operations against the Permit API and a PDP (PER-16677): create, get and list; assign and remove a user; grant and revoke a role, checking through permit.check() that a member gets the group's role on that resource instance only; nest one group in another, checking which group's members gain the other's roles; and the 404 and 409 errors. One test drives a group through the blocking client. The tests pin which identifier forms each call takes: the resource instance id or : in the path, a bare key only for a group of the default "group" type, and the instance key or id (not the qualified form) for the group in the assign_group body. Each test creates its own tenant, resource types, users and groups with unique keys and registers every delete before the create it undoes; a 404 at teardown counts as success. Co-Authored-By: Claude Opus 5.5 --- tests/test_groups_e2e.py | 500 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 tests/test_groups_e2e.py diff --git a/tests/test_groups_e2e.py b/tests/test_groups_e2e.py new file mode 100644 index 00000000..f976562e --- /dev/null +++ b/tests/test_groups_e2e.py @@ -0,0 +1,500 @@ +"""The Groups API against the Permit API and a PDP (PER-16677). + +A group is a resource instance of a group resource type. ``assign_user`` gives the user +that type's ``member`` role on the group, and ``assign_role`` grants the group a role on +one resource instance: the API links the group to that instance and derives the role +from ``member``, so the PDP grants it to every member of the group (ReBAC). A group's +role reaches its members on that instance only. + +Each test builds its own policy in the environment the API key belongs to. Every key is +unique to the run, and every delete is registered before the create it undoes, so a test +that fails part way still removes what it made. Teardown runs in reverse order of +registration, and a 404 there counts as success. +""" + +import asyncio +import functools +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack, ExitStack +from dataclasses import dataclass +from typing import Any, Final, TypeVar +from uuid import UUID + +import pytest + +from permit import Permit +from permit.api.models import GroupAddRole, GroupAssignment, GroupRead, GroupReadSchema, UserRead +from permit.exceptions import PermitApiError +from permit.sync import Permit as SyncPermit +from tests.utils import handle_cleanup_error, unique_key + +pytestmark = pytest.mark.e2e + +READ: Final[str] = "read" +VIEWER: Final[str] = "viewer" +# The role the API gives a user on the group's resource instance when it adds them to it. +MEMBER: Final[str] = "member" +# The type of a group created without one, and the type a bare group key is read as. +DEFAULT_GROUP_TYPE: Final[str] = "group" +FIRST_DOCUMENT: Final[str] = "doc-1" +SECOND_DOCUMENT: Final[str] = "doc-2" +PER_PAGE: Final[int] = 100 +NOT_FOUND: Final[int] = 404 +CONFLICT: Final[int] = 409 + +# Writes reach the PDP asynchronously, and a role a member gets through two groups +# depends on every write along the way. The bound is reached only when a decision never +# converges; polling returns as soon as it does. +PROPAGATION_TIMEOUT: Final[float] = 60.0 +POLL_INTERVAL: Final[float] = 0.5 + +T = TypeVar("T") + + +async def settled(fetch: Callable[[], Awaitable[T]], expected: T) -> T: + """Poll ``fetch`` until it returns ``expected``, for up to PROPAGATION_TIMEOUT seconds. + + The last answer is returned either way, so the caller's assertion reports the value + the PDP gave. + """ + deadline = time.monotonic() + PROPAGATION_TIMEOUT + answer = await fetch() + while answer != expected and time.monotonic() < deadline: + await asyncio.sleep(POLL_INTERVAL) + answer = await fetch() + return answer + + +async def delete_quietly(delete: Callable[[], Awaitable[None]], description: str) -> None: + """Delete one object at teardown. A 404 means it is already gone, which is the goal.""" + try: + await delete() + except PermitApiError as error: + handle_cleanup_error(error, f"could not delete {description}") + + +def delete_quietly_blocking(delete: Callable[[], None], description: str) -> None: + """Delete one object at teardown through the blocking client, as ``delete_quietly``.""" + try: + delete() + except PermitApiError as error: + handle_cleanup_error(error, f"could not delete {description}") + + +@dataclass(frozen=True) +class GroupPolicy: + """The keys of one test's policy, all unique to it.""" + + tenant: str + group_type: str + document_type: str + + def group(self, key: str) -> str: + """The group's identifier in the ``:`` form.""" + return f"{self.group_type}:{key}" + + def document(self, key: str) -> dict[str, Any]: + """One of the policy's documents, as ``permit.check`` takes a resource.""" + return {"type": self.document_type, "key": key, "tenant": self.tenant} + + def viewer_on(self, document_key: str) -> GroupAddRole: + """The ``viewer`` role on one of the policy's documents, for a group to be granted.""" + return GroupAddRole( + role=VIEWER, + resource=self.document_type, + resource_instance=document_key, + tenant=self.tenant, + ) + + +@pytest.fixture +async def teardown() -> AsyncIterator[AsyncExitStack]: + """The deletes a test and its fixtures register, run once the test ends.""" + async with AsyncExitStack() as stack: + yield stack + + +@pytest.fixture +async def group_policy(permit: Permit, teardown: AsyncExitStack) -> GroupPolicy: + """A tenant, a group resource type, and a document type with a ``viewer`` role. + + The document type has two documents in the tenant. The tests create their groups of + the group resource type and grant them ``viewer`` on one document or the other. + """ + policy = GroupPolicy( + tenant=unique_key("groups-tenant"), + group_type=unique_key("team"), + document_type=unique_key("groups-doc"), + ) + api = permit.api + + teardown.push_async_callback( + delete_quietly, + functools.partial(api.resources.delete, policy.document_type), + f"resource '{policy.document_type}'", + ) + await api.resources.create( + { + "key": policy.document_type, + "name": policy.document_type, + "actions": {READ: {}}, + "roles": {VIEWER: {"name": "Viewer", "permissions": [READ]}}, + } + ) + + teardown.push_async_callback( + delete_quietly, + functools.partial(api.resources.delete, policy.group_type), + f"resource '{policy.group_type}'", + ) + await api.resources.create( + {"key": policy.group_type, "name": policy.group_type, "actions": {READ: {}}} + ) + + teardown.push_async_callback( + delete_quietly, + functools.partial(api.tenants.delete, policy.tenant), + f"tenant '{policy.tenant}'", + ) + await api.tenants.create({"key": policy.tenant, "name": policy.tenant}) + + for document_key in (FIRST_DOCUMENT, SECOND_DOCUMENT): + document = f"{policy.document_type}:{document_key}" + teardown.push_async_callback( + delete_quietly, + functools.partial(api.resource_instances.delete, document), + f"resource instance '{document}'", + ) + await api.resource_instances.create( + {"key": document_key, "resource": policy.document_type, "tenant": policy.tenant} + ) + return policy + + +async def create_user(permit: Permit, teardown: AsyncExitStack, name: str) -> UserRead: + """Create a user with a unique key, its delete registered first.""" + key = unique_key(name) + teardown.push_async_callback( + delete_quietly, functools.partial(permit.api.users.delete, key), f"user '{key}'" + ) + return await permit.api.users.create({"key": key}) + + +async def create_group( + permit: Permit, + teardown: AsyncExitStack, + key: str, + *, + tenant: str, + group_type: str | None = None, +) -> GroupRead: + """Create a group, its delete registered first. + + Without ``group_type`` the group is created with none, so it gets the default type. + """ + group = f"{group_type or DEFAULT_GROUP_TYPE}:{key}" + teardown.push_async_callback( + delete_quietly, functools.partial(permit.api.groups.delete, group), f"group '{group}'" + ) + group_data: dict[str, Any] = {"group_instance_key": key, "group_tenant": tenant} + if group_type is not None: + group_data["group_resource_type_key"] = group_type + return await permit.api.groups.create(group_data) + + +async def find_group(permit: Permit, group_id: UUID) -> GroupReadSchema | None: + """Find a group by id across every page of ``groups.list``. + + The environment is shared, so the group is not necessarily on the first page. + """ + page = 1 + while True: + groups = (await permit.api.groups.list(page=page, per_page=PER_PAGE)).data + for group in groups: + if group.id == group_id: + return group + if len(groups) < PER_PAGE: + return None + page += 1 + + +async def memberships(permit: Permit, user_key: str, group: str) -> list[tuple[str, str | None]]: + """The roles the user is assigned on the group's resource instance, with their tenants.""" + assignments = await permit.api.role_assignments.list( + user_key=user_key, resource_instance_key=group + ) + return [(assignment.role, assignment.tenant) for assignment in assignments] + + +def blocking_memberships( + sync_permit: SyncPermit, user_key: str, group: str +) -> list[tuple[str, str | None]]: + """``memberships`` through the blocking client.""" + assignments = sync_permit.api.role_assignments.list( + user_key=user_key, resource_instance_key=group + ) + return [(assignment.role, assignment.tenant) for assignment in assignments] + + +async def assert_no_group(permit: Permit, group: str) -> None: + """Assert that ``groups.get`` answers 404 for ``group``.""" + with pytest.raises(PermitApiError) as missing: + await permit.api.groups.get(group) + assert missing.value.status_code == NOT_FOUND, f"group '{group}' still exists" + + +async def ensure_default_group_type(permit: Permit, teardown: AsyncExitStack) -> None: + """Create the default group resource type, unless the environment already has one. + + Only a type this test created is deleted at teardown: one that was already there + belongs to whoever made it. + """ + try: + await permit.api.resources.get(DEFAULT_GROUP_TYPE) + except PermitApiError as error: + if error.status_code != NOT_FOUND: + raise + teardown.push_async_callback( + delete_quietly, + functools.partial(permit.api.resources.delete, DEFAULT_GROUP_TYPE), + f"resource '{DEFAULT_GROUP_TYPE}'", + ) + await permit.api.resources.create( + {"key": DEFAULT_GROUP_TYPE, "name": "Group", "actions": {READ: {}}} + ) + + +async def test_list_and_get_return_the_group( + permit: Permit, teardown: AsyncExitStack, group_policy: GroupPolicy +) -> None: + policy = group_policy + key = unique_key("eng") + + created = await create_group( + permit, teardown, key, tenant=policy.tenant, group_type=policy.group_type + ) + + assert created.group_instance_key == key + assert created.group_resource_type_key == policy.group_type + assert created.group_tenant == policy.tenant + assert not created.users + + fetched = await permit.api.groups.get(policy.group(key)) + assert fetched.group_instance_key == key + assert fetched.group_resource_type_key == policy.group_type + assert fetched.group_tenant == policy.tenant + assert await permit.api.groups.get(str(fetched.id)) == fetched + assert await find_group(permit, fetched.id) == fetched + # A bare key is read as a group of the default type, so it does not name this group. + await assert_no_group(permit, key) + + +@pytest.mark.parametrize("form", ["resource_key:instance_key", "id"]) +async def test_a_group_is_managed_by_either_identifier( + permit: Permit, teardown: AsyncExitStack, group_policy: GroupPolicy, form: str +) -> None: + policy = group_policy + key = unique_key("eng") + group = policy.group(key) + user = await create_user(permit, teardown, "group-member") + await create_group(permit, teardown, key, tenant=policy.tenant, group_type=policy.group_type) + fetched = await permit.api.groups.get(group) + identifier = str(fetched.id) if form == "id" else group + + joined = await permit.api.groups.assign_user(identifier, user.key, policy.tenant) + + assert joined.group_instance_key == key + assert user.id in (joined.users or []) + assert await memberships(permit, user.key, group) == [(MEMBER, policy.tenant)] + + await permit.api.groups.remove_user(identifier, user.key, policy.tenant) + + assert await memberships(permit, user.key, group) == [] + + await permit.api.groups.delete(identifier) + + await assert_no_group(permit, group) + with pytest.raises(PermitApiError) as deleted_twice: + await permit.api.groups.delete(identifier) + assert deleted_twice.value.status_code == NOT_FOUND + + +async def test_a_bare_key_names_a_group_of_the_default_type( + permit: Permit, teardown: AsyncExitStack +) -> None: + tenant = unique_key("groups-tenant") + key = unique_key("eng") + await ensure_default_group_type(permit, teardown) + teardown.push_async_callback( + delete_quietly, functools.partial(permit.api.tenants.delete, tenant), f"tenant '{tenant}'" + ) + await permit.api.tenants.create({"key": tenant, "name": tenant}) + user = await create_user(permit, teardown, "group-member") + + created = await create_group(permit, teardown, key, tenant=tenant) + + assert created.group_resource_type_key == DEFAULT_GROUP_TYPE + by_key = await permit.api.groups.get(key) + assert by_key.group_instance_key == key + assert by_key.group_resource_type_key == DEFAULT_GROUP_TYPE + assert await permit.api.groups.get(f"{DEFAULT_GROUP_TYPE}:{key}") == by_key + assert await permit.api.groups.get(str(by_key.id)) == by_key + + joined = await permit.api.groups.assign_user(key, user.key, tenant) + + assert user.id in (joined.users or []) + group = f"{DEFAULT_GROUP_TYPE}:{key}" + assert await memberships(permit, user.key, group) == [(MEMBER, tenant)] + + await permit.api.groups.remove_user(key, user.key, tenant) + + assert await memberships(permit, user.key, group) == [] + + await permit.api.groups.delete(key) + + await assert_no_group(permit, group) + + +async def test_a_role_granted_to_a_group_reaches_its_members( + permit: Permit, teardown: AsyncExitStack, group_policy: GroupPolicy +) -> None: + policy = group_policy + groups = permit.api.groups + key = unique_key("eng") + group = policy.group(key) + member = await create_user(permit, teardown, "group-member") + outsider = await create_user(permit, teardown, "group-outsider") + await create_group(permit, teardown, key, tenant=policy.tenant, group_type=policy.group_type) + first, second = policy.document(FIRST_DOCUMENT), policy.document(SECOND_DOCUMENT) + + granted = await groups.assign_role(group, policy.viewer_on(FIRST_DOCUMENT)) + joined = await groups.assign_user(group, member.key, policy.tenant) + + assert f"{policy.document_type}:{FIRST_DOCUMENT}#{VIEWER}" in (granted.assigned_roles or []) + assert member.id in (joined.users or []) + allowed = await settled(lambda: permit.check(member.key, READ, first), expected=True) + assert allowed is True, f"'{member.key}' never got the group's role on {first}" + # The role holds on the instance the group was granted it on, for the group's + # members: not on the type's other document, and not for a user outside the group. + assert await permit.check(member.key, READ, second) is False + assert await permit.check(outsider.key, READ, first) is False + + await groups.remove_user(group, member.key, policy.tenant) + + allowed = await settled(lambda: permit.check(member.key, READ, first), expected=False) + assert allowed is False, f"'{member.key}' kept the group's role after leaving it" + + await groups.assign_user(group, member.key, policy.tenant) + allowed = await settled(lambda: permit.check(member.key, READ, first), expected=True) + assert allowed is True, f"'{member.key}' did not get the group's role back on rejoining" + + await groups.remove_role(group, policy.viewer_on(FIRST_DOCUMENT)) + + allowed = await settled(lambda: permit.check(member.key, READ, first), expected=False) + assert allowed is False, f"'{member.key}' kept a role the group no longer has" + + +async def test_assign_group_makes_the_group_a_member_of_the_other( + permit: Permit, teardown: AsyncExitStack, group_policy: GroupPolicy +) -> None: + policy = group_policy + groups = permit.api.groups + inner_key, outer_key = unique_key("backend"), unique_key("engineering") + inner, outer = policy.group(inner_key), policy.group(outer_key) + inner_member = await create_user(permit, teardown, "inner-member") + outer_member = await create_user(permit, teardown, "outer-member") + for key in (inner_key, outer_key): + await create_group( + permit, teardown, key, tenant=policy.tenant, group_type=policy.group_type + ) + first, second = policy.document(FIRST_DOCUMENT), policy.document(SECOND_DOCUMENT) + await groups.assign_role(outer, policy.viewer_on(FIRST_DOCUMENT)) + await groups.assign_role(inner, policy.viewer_on(SECOND_DOCUMENT)) + await groups.assign_user(outer, outer_member.key, policy.tenant) + await groups.assign_user(inner, inner_member.key, policy.tenant) + + nested = await groups.assign_group(inner, GroupAssignment(group_instance_key=outer_key)) + + assert nested.group_instance_key == inner_key + # The members of the group in the path become members of the group in the body, so + # they get its roles and keep their own. + allowed = await settled(lambda: permit.check(inner_member.key, READ, first), expected=True) + assert allowed is True, f"'{inner_member.key}' never got the outer group's role" + allowed = await settled(lambda: permit.check(inner_member.key, READ, second), expected=True) + assert allowed is True, f"'{inner_member.key}' lost the inner group's own role" + # The other way round, nothing: the outer group's members do not get the inner + # group's role. That role has reached the PDP (checked just above), and so has the + # outer member's own one (checked here first). + allowed = await settled(lambda: permit.check(outer_member.key, READ, first), expected=True) + assert allowed is True, f"'{outer_member.key}' never got the outer group's role" + assert await permit.check(outer_member.key, READ, second) is False + + with pytest.raises(PermitApiError) as duplicate: + await groups.assign_group(inner, {"group_instance_key": outer_key}) + assert duplicate.value.status_code == CONFLICT + # The group in the body is looked up by its instance key or id among groups of the + # path group's type; the qualified form names none of them. + with pytest.raises(PermitApiError) as qualified: + await groups.assign_group(inner, {"group_instance_key": outer}) + assert qualified.value.status_code == NOT_FOUND + + await groups.remove_group(inner, GroupAssignment(group_instance_key=outer_key)) + + allowed = await settled(lambda: permit.check(inner_member.key, READ, first), expected=False) + assert allowed is False, f"'{inner_member.key}' kept the outer group's role" + assert await permit.check(inner_member.key, READ, second) is True + + +def test_the_blocking_client_manages_a_group(sync_permit: SyncPermit) -> None: + api = sync_permit.api + tenant = unique_key("groups-tenant") + group_type = unique_key("team") + key = unique_key("eng") + group = f"{group_type}:{key}" + user_key = unique_key("group-member") + with ExitStack() as teardown: + teardown.callback( + delete_quietly_blocking, + functools.partial(api.resources.delete, group_type), + f"resource '{group_type}'", + ) + api.resources.create({"key": group_type, "name": group_type, "actions": {READ: {}}}) + teardown.callback( + delete_quietly_blocking, + functools.partial(api.tenants.delete, tenant), + f"tenant '{tenant}'", + ) + api.tenants.create({"key": tenant, "name": tenant}) + teardown.callback( + delete_quietly_blocking, + functools.partial(api.users.delete, user_key), + f"user '{user_key}'", + ) + user = api.users.create({"key": user_key}) + teardown.callback( + delete_quietly_blocking, functools.partial(api.groups.delete, group), f"group '{group}'" + ) + + created = api.groups.create( + { + "group_resource_type_key": group_type, + "group_instance_key": key, + "group_tenant": tenant, + } + ) + + assert created.group_instance_key == key + fetched = api.groups.get(group) + assert fetched.group_instance_key == key + assert fetched.group_resource_type_key == group_type + assert fetched.group_tenant == tenant + joined = api.groups.assign_user(group, user_key, tenant) + assert user.id in (joined.users or []) + assert blocking_memberships(sync_permit, user_key, group) == [(MEMBER, tenant)] + api.groups.remove_user(group, user_key, tenant) + assert blocking_memberships(sync_permit, user_key, group) == [] + api.groups.delete(group) + with pytest.raises(PermitApiError) as missing: + api.groups.get(group) + assert missing.value.status_code == NOT_FOUND From 34693485c1f4132a4d744fba6f253199f47bd2e3 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 14:55:19 +0300 Subject: [PATCH 05/12] Check in e2e that creating an existing group answers 409 GroupsApi.create documents a 409 for a group that already exists. The e2e test now creates the same group a second time and expects that status, so CI confirms the documented error. Co-Authored-By: Claude Opus 5.5 --- tests/test_groups_e2e.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_groups_e2e.py b/tests/test_groups_e2e.py index f976562e..b88acfb7 100644 --- a/tests/test_groups_e2e.py +++ b/tests/test_groups_e2e.py @@ -279,6 +279,15 @@ async def test_list_and_get_return_the_group( assert created.group_resource_type_key == policy.group_type assert created.group_tenant == policy.tenant assert not created.users + with pytest.raises(PermitApiError) as duplicate: + await permit.api.groups.create( + { + "group_resource_type_key": policy.group_type, + "group_instance_key": key, + "group_tenant": policy.tenant, + } + ) + assert duplicate.value.status_code == CONFLICT fetched = await permit.api.groups.get(policy.group(key)) assert fetched.group_instance_key == key From 14036da010c372f7fabd7a160e00b1b48d11c072 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 14:55:39 +0300 Subject: [PATCH 06/12] Name the group identifier forms in e2e as the docstrings do The GroupsApi docstrings and the README call the qualified identifier ":". The e2e module now uses the same name in its parameter ids, helper docstring and comments. Co-Authored-By: Claude Opus 5.5 --- tests/test_groups_e2e.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_groups_e2e.py b/tests/test_groups_e2e.py index b88acfb7..e76b1deb 100644 --- a/tests/test_groups_e2e.py +++ b/tests/test_groups_e2e.py @@ -91,7 +91,7 @@ class GroupPolicy: document_type: str def group(self, key: str) -> str: - """The group's identifier in the ``:`` form.""" + """The group's identifier in the ``":"`` form.""" return f"{self.group_type}:{key}" def document(self, key: str) -> dict[str, Any]: @@ -299,7 +299,7 @@ async def test_list_and_get_return_the_group( await assert_no_group(permit, key) -@pytest.mark.parametrize("form", ["resource_key:instance_key", "id"]) +@pytest.mark.parametrize("form", ["type:key", "id"]) async def test_a_group_is_managed_by_either_identifier( permit: Permit, teardown: AsyncExitStack, group_policy: GroupPolicy, form: str ) -> None: @@ -443,10 +443,10 @@ async def test_assign_group_makes_the_group_a_member_of_the_other( await groups.assign_group(inner, {"group_instance_key": outer_key}) assert duplicate.value.status_code == CONFLICT # The group in the body is looked up by its instance key or id among groups of the - # path group's type; the qualified form names none of them. - with pytest.raises(PermitApiError) as qualified: + # path group's type; the ":" form names none of them. + with pytest.raises(PermitApiError) as type_and_key: await groups.assign_group(inner, {"group_instance_key": outer}) - assert qualified.value.status_code == NOT_FOUND + assert type_and_key.value.status_code == NOT_FOUND await groups.remove_group(inner, GroupAssignment(group_instance_key=outer_key)) From fd17a993dc47dc19250c369982a3f9d9f87c14eb Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 15:17:43 +0300 Subject: [PATCH 07/12] Say in the README which group forms assign_group's body takes The README's identifier bullet read as if every group_instance_key in the Groups API took ":". It applies to the first argument only. The group in the assign_group()/remove_group() body is named by its instance id or its key alone, and both groups must be of the same resource type; ":" there answers 404. The docstrings and the e2e tests already say this. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d73ef0c0..2cd909da 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,14 @@ await permit.check("alice", "edit", {"type": "document", "key": "readme", "tenan - A role granted to a group is a resource role on one resource instance. Members get it through ReBAC role derivation over the group instance, so `permit.check()` allows it on that instance. It is not a tenant-wide (RBAC) role. -- A method's `group_instance_key` takes the group's instance id, `":"` such as - `"group:engineering"` or `"team:engineering"`, or the key alone (`"engineering"`), which - finds only groups of the `group` resource type. +- A method's first argument, `group_instance_key`, takes the group's instance id, + `":"` such as `"group:engineering"` or `"team:engineering"`, or the key alone + (`"engineering"`), which finds only groups of the `group` resource type. - `assign_group("group:leads", {"group_instance_key": "engineering"})` makes the members of `leads` members of `engineering`, so they get the roles granted to `engineering`. The - members of `engineering` get nothing from `leads`. `remove_group()` undoes it. + members of `engineering` get nothing from `leads`. `remove_group()` undoes it. Both groups + must be of the same resource type, and the second argument names its group by instance id + or by key alone, never `":"`. - The other methods are `list()`, `get()`, `delete()`, `remove_user()` and `remove_role()`. The blocking client, `permit.sync.Permit`, has the same methods. From 96ee014dcb7fd64ab3898aa2baa19fdd2e58f604 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 15:18:04 +0300 Subject: [PATCH 08/12] Document that assign_role answers 409 for another tenant's instance assign_role() looks up the resource instance in the group's tenant and creates it there when the lookup misses. An instance key is unique within its resource type regardless of tenant, so an instance with that key in another tenant makes the create fail with 409. The docstring now says the instance must be in the group's tenant and lists the 409. Co-Authored-By: Claude Opus 5.5 --- permit/_sync_types.pyi | 9 ++++++--- permit/api/groups.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 379bb252..3374983d 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -679,15 +679,18 @@ class SyncGroupsApi(BasePermitApi): role_data: What to grant. ``role`` is the key or id of a role of ``resource``. ``resource`` is the resource's key and ``resource_instance`` the instance's key: the API looks up ``":"`` in the group's - tenant and creates the instance there if it does not exist. ``tenant`` is - required by the API: pass the group's tenant. + tenant and creates the instance there if it does not exist. The instance + must be in the group's tenant: an instance key is unique within its + resource type, so if the instance is in another tenant the API answers 409. + ``tenant`` is required by the API: pass the group's tenant. Returns: The group, with the ids of the users added to it and its assigned roles. Raises: PermitApiError: If the API returns an error HTTP status code, such as 404 when the - group, the resource or the role does not exist. + group, the resource or the role does not exist, or 409 when the resource + instance is in another tenant than the group. PermitContextError: If the configured ApiContext does not match the required endpoint context. """ diff --git a/permit/api/groups.py b/permit/api/groups.py index b36d2d6d..7e6691ee 100644 --- a/permit/api/groups.py +++ b/permit/api/groups.py @@ -253,15 +253,18 @@ async def assign_role( role_data: What to grant. ``role`` is the key or id of a role of ``resource``. ``resource`` is the resource's key and ``resource_instance`` the instance's key: the API looks up ``":"`` in the group's - tenant and creates the instance there if it does not exist. ``tenant`` is - required by the API: pass the group's tenant. + tenant and creates the instance there if it does not exist. The instance + must be in the group's tenant: an instance key is unique within its + resource type, so if the instance is in another tenant the API answers 409. + ``tenant`` is required by the API: pass the group's tenant. Returns: The group, with the ids of the users added to it and its assigned roles. Raises: PermitApiError: If the API returns an error HTTP status code, such as 404 when the - group, the resource or the role does not exist. + group, the resource or the role does not exist, or 409 when the resource + instance is in another tenant than the group. PermitContextError: If the configured ApiContext does not match the required endpoint context. """ From e4dc8edb7a5764eee53e74aaeae2e4a52245dc39 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 15:18:27 +0300 Subject: [PATCH 09/12] Say what makes a resource type a group type in GroupsApi docs The API treats any resource type with a "member" role as a group resource type, so groups.list() also returns the instances of types that were never meant as groups. The class docstring now defines a group resource type that way, and list() says what it returns. Co-Authored-By: Claude Opus 5.5 --- permit/_sync_types.pyi | 19 +++++++++++++------ permit/api/groups.py | 19 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 3374983d..efdc3c0f 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -503,12 +503,16 @@ class SyncGroupsApi(BasePermitApi): """Manage groups, whose members inherit the roles granted to the group. A group is an instance of a group resource type (``group`` unless you name another) in - one tenant. A user added to a group gets the ``member`` role on the group instance. A - role granted to a group is a resource role on one resource instance, and it reaches the - group's members through ReBAC: a role derivation grants it to every user who has the - ``member`` role on the group instance. ``permit.check()`` then allows a member what that - role allows on that instance. It is not a tenant-wide (RBAC) role: a check must name - the resource instance, and a check that names only the resource type does not use it. + one tenant. A group resource type is any resource type with a ``member`` role; + ``create()`` adds that role to the type if it has none. A user added to a group gets + the ``member`` role on the group instance. + + A role granted to a group is a resource role on one resource instance, and it reaches + the group's members through ReBAC: a role derivation grants it to every user who has + the ``member`` role on the group instance. ``permit.check()`` then allows a member what + that role allows on that instance. It is not a tenant-wide (RBAC) role: a check must + name the resource instance, and a check that names only the resource type does not use + it. Every method needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. @@ -526,6 +530,9 @@ class SyncGroupsApi(BasePermitApi): def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultGroupReadSchema: """Lists the environment's groups, of every group resource type. + The API returns the instances of every resource type that has a ``member`` role, + including types that were not made for groups. + Needs an environment-level API key, or a broader key with the SDK's API context set to the environment. diff --git a/permit/api/groups.py b/permit/api/groups.py index 7e6691ee..7ab0ca84 100644 --- a/permit/api/groups.py +++ b/permit/api/groups.py @@ -28,12 +28,16 @@ class GroupsApi(BasePermitApi): """Manage groups, whose members inherit the roles granted to the group. A group is an instance of a group resource type (``group`` unless you name another) in - one tenant. A user added to a group gets the ``member`` role on the group instance. A - role granted to a group is a resource role on one resource instance, and it reaches the - group's members through ReBAC: a role derivation grants it to every user who has the - ``member`` role on the group instance. ``permit.check()`` then allows a member what that - role allows on that instance. It is not a tenant-wide (RBAC) role: a check must name - the resource instance, and a check that names only the resource type does not use it. + one tenant. A group resource type is any resource type with a ``member`` role; + ``create()`` adds that role to the type if it has none. A user added to a group gets + the ``member`` role on the group instance. + + A role granted to a group is a resource role on one resource instance, and it reaches + the group's members through ReBAC: a role derivation grants it to every user who has + the ``member`` role on the group instance. ``permit.check()`` then allows a member what + that role allows on that instance. It is not a tenant-wide (RBAC) role: a check must + name the resource instance, and a check that names only the resource type does not use + it. Every method needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. @@ -59,6 +63,9 @@ def __groups(self) -> SimpleHttpClient: async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultGroupReadSchema: """Lists the environment's groups, of every group resource type. + The API returns the instances of every resource type that has a ``member`` role, + including types that were not made for groups. + Needs an environment-level API key, or a broader key with the SDK's API context set to the environment. From 6aeefba0839309e508b45ccd201fef6dbd79b586 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 15:19:09 +0300 Subject: [PATCH 10/12] Test groups API errors with the error body the API sends The 404/409 test answered with a body that is not an ErrorDetails, so the SDK raised its plain PermitApiError fallback, which a real API error never reaches. The test now answers with an API-shaped body (id, title, NOT_FOUND or DUPLICATE_ENTITY) and checks that every groups method raises PermitNotFoundError or PermitAlreadyExistsError, with the status and the body. Co-Authored-By: Claude Opus 5.5 --- tests/test_groups_offline.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/test_groups_offline.py b/tests/test_groups_offline.py index b5247b92..8daafd66 100644 --- a/tests/test_groups_offline.py +++ b/tests/test_groups_offline.py @@ -28,7 +28,7 @@ PaginatedResultGroupReadSchema, ) from permit.config import PermitConfig -from permit.exceptions import PermitApiError +from permit.exceptions import PermitAlreadyExistsError, PermitApiError, PermitNotFoundError from permit.sync import Permit as SyncPermit from tests.utils import SCHEMA, Call, call, sent @@ -334,21 +334,41 @@ def test_request_and_response( assert result == case.model.parse_obj(case.response) +class ApiError(NamedTuple): + """An error status, the error code the API sends with it, and what the SDK raises.""" + + status: int + error_code: str + raises: type[PermitApiError] + + +API_ERRORS = { + "404": ApiError(404, "NOT_FOUND", PermitNotFoundError), + "409": ApiError(409, "DUPLICATE_ENTITY", PermitAlreadyExistsError), +} + + @pytest.mark.parametrize("flavour", ["async", "sync"]) -@pytest.mark.parametrize("status", [404, 409]) +@pytest.mark.parametrize("error", API_ERRORS.values(), ids=API_ERRORS.keys()) @pytest.mark.parametrize("case", BASIC_CASES.values(), ids=BASIC_CASES.keys()) -def test_error_status_raises_permit_api_error( - httpserver: HTTPServer, config: PermitConfig, case: Case, status: int, flavour: str +def test_an_api_error_raises_the_matching_permit_api_error( + httpserver: HTTPServer, config: PermitConfig, case: Case, error: ApiError, flavour: str ) -> None: - detail = {"error_code": "ERROR", "message": f"status {status}"} + detail = { + "id": "request-1", + "title": f"status {error.status}", + "error_code": error.error_code, + "message": f"status {error.status}", + } httpserver.expect_request(case.path, method=case.method).respond_with_json( - detail, status=status + detail, status=error.status ) with pytest.raises(PermitApiError) as raised: invoke(config, flavour, case.call) - assert raised.value.status_code == status + assert type(raised.value) is error.raises + assert raised.value.status_code == error.status assert raised.value.details == detail assert len(httpserver.log) == 1 From 685603ee48e19e06f2ca6c84b3e79fa149c6d24e Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 15:19:50 +0300 Subject: [PATCH 11/12] Test that groups calls need an environment API context Every GroupsApi docstring says it needs an environment-level key or a broader key with the API context set to an environment, but no test pinned it. A project-level key whose context is the project now gets PermitContextError from every groups method, on both clients, and nothing is sent. Co-Authored-By: Claude Opus 5.5 --- tests/test_groups_offline.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/test_groups_offline.py b/tests/test_groups_offline.py index 8daafd66..de0bccc1 100644 --- a/tests/test_groups_offline.py +++ b/tests/test_groups_offline.py @@ -28,9 +28,14 @@ PaginatedResultGroupReadSchema, ) from permit.config import PermitConfig -from permit.exceptions import PermitAlreadyExistsError, PermitApiError, PermitNotFoundError +from permit.exceptions import ( + PermitAlreadyExistsError, + PermitApiError, + PermitContextError, + PermitNotFoundError, +) from permit.sync import Permit as SyncPermit -from tests.utils import SCHEMA, Call, call, sent +from tests.utils import ORG, PROJECT, SCHEMA, Call, call, sent GROUPS = f"{SCHEMA}/groups" GROUP_ID = "00000000-0000-4000-8000-000000000010" @@ -373,6 +378,21 @@ def test_an_api_error_raises_the_matching_permit_api_error( assert len(httpserver.log) == 1 +@pytest.mark.parametrize("flavour", ["async", "sync"]) +@pytest.mark.parametrize("case", BASIC_CASES.values(), ids=BASIC_CASES.keys()) +def test_a_project_context_is_refused_before_sending( + httpserver: HTTPServer, config: PermitConfig, case: Case, flavour: str +) -> None: + """A project-level key needs the SDK's API context set to an environment first.""" + config.api_context._save_api_key_accessible_scope(org=ORG, project=PROJECT) + config.api_context.set_project_level_context(ORG, PROJECT) + + with pytest.raises(PermitContextError): + invoke(config, flavour, case.call) + + assert httpserver.log == [] + + @pytest.mark.parametrize("flavour", ["async", "sync"]) @pytest.mark.parametrize("target", INVALID_DICTS.values(), ids=INVALID_DICTS.keys()) def test_an_invalid_dict_is_rejected_before_sending( From 7f2c8fffa05c7e5ea3fa1080fb9de10f7abbf79d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 15:22:06 +0300 Subject: [PATCH 12/12] Share the e2e polling and quiet-delete helpers in tests/utils test_cloud_pdp_e2e.py and test_groups_e2e.py each had the same delete_quietly and the same bounded poll loop, differing only in their timeout and interval. Both now live in tests/utils.py: delete_quietly as it was, and poll_for, which takes the timeout and interval. Each module binds its own values as settled, so the call sites and the polling bounds are unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/test_cloud_pdp_e2e.py | 40 ++++++++----------------------------- tests/test_groups_e2e.py | 32 ++++------------------------- tests/utils.py | 32 ++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 61 deletions(-) diff --git a/tests/test_cloud_pdp_e2e.py b/tests/test_cloud_pdp_e2e.py index 1c59e7fa..2798aac6 100644 --- a/tests/test_cloud_pdp_e2e.py +++ b/tests/test_cloud_pdp_e2e.py @@ -10,20 +10,17 @@ about need not exist as resource instances. """ -import asyncio import functools import os -import time -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator from contextlib import AsyncExitStack from dataclasses import dataclass -from typing import Any, Final, TypeVar +from typing import Any, Final import pytest from permit import Permit -from permit.exceptions import PermitApiError -from tests.utils import handle_cleanup_error, unique_key +from tests.utils import delete_quietly, poll_for, unique_key CLOUD_PDP_URL: Final[str] = "https://cloudpdp.api.permit.io" @@ -58,32 +55,11 @@ PROPAGATION_TIMEOUT: Final[float] = 120.0 POLL_INTERVAL: Final[float] = 1.0 -T = TypeVar("T") - - -async def settled(fetch: Callable[[], Awaitable[T]], expected: T) -> T: - """Poll ``fetch`` until it returns ``expected``, for up to PROPAGATION_TIMEOUT seconds. - - An answer that includes an allow is polled for rather than asserted once: the cloud - PDP applies writes asynchronously, and one answer that reflects a write does not - guarantee the next one will. A deny is asserted once, since no stage of propagation - turns it into an allow. The last answer is returned either way, so the caller's - assertion reports the value the PDP gave. - """ - deadline = time.monotonic() + PROPAGATION_TIMEOUT - answer = await fetch() - while answer != expected and time.monotonic() < deadline: - await asyncio.sleep(POLL_INTERVAL) - answer = await fetch() - return answer - - -async def delete_quietly(delete: Callable[[], Awaitable[None]], description: str) -> None: - """Delete one object at teardown. A 404 means it is already gone, which is the goal.""" - try: - await delete() - except PermitApiError as error: - handle_cleanup_error(error, f"could not delete {description}") +# An answer that includes an allow is polled for rather than asserted once: the cloud PDP +# applies writes asynchronously, and one answer that reflects a write does not guarantee +# the next one will. A deny is asserted once, since no stage of propagation turns it into +# an allow. +settled = functools.partial(poll_for, timeout=PROPAGATION_TIMEOUT, interval=POLL_INTERVAL) @dataclass(frozen=True) diff --git a/tests/test_groups_e2e.py b/tests/test_groups_e2e.py index e76b1deb..dc8a7141 100644 --- a/tests/test_groups_e2e.py +++ b/tests/test_groups_e2e.py @@ -12,13 +12,11 @@ registration, and a 404 there counts as success. """ -import asyncio import functools -import time -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Callable from contextlib import AsyncExitStack, ExitStack from dataclasses import dataclass -from typing import Any, Final, TypeVar +from typing import Any, Final from uuid import UUID import pytest @@ -27,7 +25,7 @@ from permit.api.models import GroupAddRole, GroupAssignment, GroupRead, GroupReadSchema, UserRead from permit.exceptions import PermitApiError from permit.sync import Permit as SyncPermit -from tests.utils import handle_cleanup_error, unique_key +from tests.utils import delete_quietly, handle_cleanup_error, poll_for, unique_key pytestmark = pytest.mark.e2e @@ -49,29 +47,7 @@ PROPAGATION_TIMEOUT: Final[float] = 60.0 POLL_INTERVAL: Final[float] = 0.5 -T = TypeVar("T") - - -async def settled(fetch: Callable[[], Awaitable[T]], expected: T) -> T: - """Poll ``fetch`` until it returns ``expected``, for up to PROPAGATION_TIMEOUT seconds. - - The last answer is returned either way, so the caller's assertion reports the value - the PDP gave. - """ - deadline = time.monotonic() + PROPAGATION_TIMEOUT - answer = await fetch() - while answer != expected and time.monotonic() < deadline: - await asyncio.sleep(POLL_INTERVAL) - answer = await fetch() - return answer - - -async def delete_quietly(delete: Callable[[], Awaitable[None]], description: str) -> None: - """Delete one object at teardown. A 404 means it is already gone, which is the goal.""" - try: - await delete() - except PermitApiError as error: - handle_cleanup_error(error, f"could not delete {description}") +settled = functools.partial(poll_for, timeout=PROPAGATION_TIMEOUT, interval=POLL_INTERVAL) def delete_quietly_blocking(delete: Callable[[], None], description: str) -> None: diff --git a/tests/utils.py b/tests/utils.py index 7e153a2e..9cafabf3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,9 @@ +import asyncio import json +import time import uuid -from typing import Any, NamedTuple +from collections.abc import Awaitable, Callable +from typing import Any, NamedTuple, TypeVar import pytest from loguru import logger @@ -99,6 +102,33 @@ def handle_cleanup_error(error: PermitApiError, message: str) -> None: handle_api_error(error, message) +async def delete_quietly(delete: Callable[[], Awaitable[None]], description: str) -> None: + """Delete one object at teardown. A 404 means it is already gone, which is the goal.""" + try: + await delete() + except PermitApiError as error: + handle_cleanup_error(error, f"could not delete {description}") + + +T = TypeVar("T") + + +async def poll_for( + fetch: Callable[[], Awaitable[T]], expected: T, *, timeout: float, interval: float +) -> T: + """Poll ``fetch`` every ``interval`` seconds until it returns ``expected``. + + It stops after ``timeout`` seconds. The last answer is returned either way, so the + caller's assertion reports the value it got. + """ + deadline = time.monotonic() + timeout + answer = await fetch() + while answer != expected and time.monotonic() < deadline: + await asyncio.sleep(interval) + answer = await fetch() + return answer + + def unique_key(prefix: str) -> str: """A key no concurrently-running test can collide with.