Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f52d913
Add the Groups API to the async and sync clients
zeevmoney Oct 1, 2026
90341bb
Test the Groups API requests offline on both clients
zeevmoney Oct 1, 2026
b7bf938
Document the Groups API in the README
zeevmoney Oct 1, 2026
d7a9860
Add end-to-end tests for the Groups API
zeevmoney Oct 1, 2026
ba8fc14
Merge the Groups API SDK branch into the Groups API PR
zeevmoney Oct 1, 2026
8b87997
Merge the Groups API e2e test branch into the Groups API PR
zeevmoney Oct 1, 2026
3469348
Check in e2e that creating an existing group answers 409
zeevmoney Oct 1, 2026
14036da
Name the group identifier forms in e2e as the docstrings do
zeevmoney Oct 1, 2026
fd17a99
Say in the README which group forms assign_group's body takes
zeevmoney Oct 1, 2026
96ee014
Document that assign_role answers 409 for another tenant's instance
zeevmoney Oct 1, 2026
e4dc8ed
Say what makes a resource type a group type in GroupsApi docs
zeevmoney Oct 1, 2026
6aeefba
Test groups API errors with the error body the API sends
zeevmoney Oct 1, 2026
685603e
Test that groups calls need an environment API context
zeevmoney Oct 1, 2026
7f2c8ff
Share the e2e polling and quiet-delete helpers in tests/utils
zeevmoney Oct 1, 2026
94f2b2b
Merge the invites e2e fix from the base branch
zeevmoney Oct 1, 2026
5de466c
Merge the RBAC e2e polling fix from the base branch
zeevmoney Oct 1, 2026
ff76d5b
Merge the base branch's RBAC e2e polling fix
zeevmoney Oct 2, 2026
9636773
Merge the review fixes from the base branch
zeevmoney Oct 2, 2026
fcb932e
Merge the base branch's backported e2e and schema fixes
zeevmoney Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
`"<type>:<key>"` 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 `"<type>:<key>"`.
- 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
Expand Down
292 changes: 292 additions & 0 deletions permit/_sync_types.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ from permit.api.models import (
EnvironmentRead,
EnvironmentStats,
EnvironmentUpdate,
GroupAddRole,
GroupAssignment,
GroupCreate,
GroupRead,
GroupReadSchema,
PaginatedResultElementsUserInviteRead,
PaginatedResultGroupReadSchema,
PaginatedResultRelationRead,
PaginatedResultUserRead,
PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings,
Expand Down Expand Up @@ -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;
- ``"<type>:<key>"``, 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 ``"<type>:<key>"`` 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 ``"<type>:<key>"`` 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 ``"<type>:<key>"`` 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(<this 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 ``"<type>:<key>"`` 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 ``"<type>:<key>"`` 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 ``"<type>:<key>"`` 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 ``"<resource>:<resource_instance>"`` 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 ``"<type>:<key>"`` 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 ``"<type>:<key>"`` 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
``"<type>:<key>"`` 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 ``"<type>:<key>"`` 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
``"<type>:<key>"`` 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: ...
Expand Down
10 changes: 10 additions & 0 deletions permit/api/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Loading