diff --git a/README.md b/README.md index 01e3ed49..2cd909da 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,37 @@ 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 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. 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. + ## Type checking The package ships a `py.typed` marker (PEP 561), so mypy, pyright and IDEs check your diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 558bcc48..efdc3c0f 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,292 @@ 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 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. + + 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. + + 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. + + 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. 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, 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. + """ + 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..7ab0ca84 --- /dev/null +++ b/permit/api/groups.py @@ -0,0 +1,384 @@ +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 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. + + 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. + + 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. + + 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. 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, 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. + """ + 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_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_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/test_groups_e2e.py b/tests/test_groups_e2e.py new file mode 100644 index 00000000..dc8a7141 --- /dev/null +++ b/tests/test_groups_e2e.py @@ -0,0 +1,485 @@ +"""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 functools +from collections.abc import AsyncIterator, Callable +from contextlib import AsyncExitStack, ExitStack +from dataclasses import dataclass +from typing import Any, Final +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 delete_quietly, handle_cleanup_error, poll_for, 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 + +settled = functools.partial(poll_for, timeout=PROPAGATION_TIMEOUT, interval=POLL_INTERVAL) + + +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 + 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 + 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", ["type: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 ":" form names none of them. + with pytest.raises(PermitApiError) as type_and_key: + await groups.assign_group(inner, {"group_instance_key": outer}) + assert type_and_key.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 diff --git a/tests/test_groups_offline.py b/tests/test_groups_offline.py new file mode 100644 index 00000000..de0bccc1 --- /dev/null +++ b/tests/test_groups_offline.py @@ -0,0 +1,404 @@ +"""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 ( + PermitAlreadyExistsError, + PermitApiError, + PermitContextError, + PermitNotFoundError, +) +from permit.sync import Permit as SyncPermit +from tests.utils import ORG, PROJECT, 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) + + +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("error", API_ERRORS.values(), ids=API_ERRORS.keys()) +@pytest.mark.parametrize("case", BASIC_CASES.values(), ids=BASIC_CASES.keys()) +def test_an_api_error_raises_the_matching_permit_api_error( + httpserver: HTTPServer, config: PermitConfig, case: Case, error: ApiError, flavour: str +) -> None: + 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=error.status + ) + + with pytest.raises(PermitApiError) as raised: + invoke(config, flavour, case.call) + + assert type(raised.value) is error.raises + assert raised.value.status_code == error.status + assert raised.value.details == detail + 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( + httpserver: HTTPServer, config: PermitConfig, target: Call, flavour: str +) -> None: + with pytest.raises(ValidationError): + invoke(config, flavour, target) + + assert httpserver.log == [] 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]) 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.