diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index 4f8f50e2..24d2f5e8 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -5,6 +5,7 @@ from workos._client_configuration import ClientConfiguration from workos.api_keys import ApiKeysModule from workos.audit_logs import AuditLogsModule +from workos.authorization import AuthorizationModule from workos.directory_sync import DirectorySyncModule from workos.events import EventsModule from workos.fga import FGAModule @@ -74,6 +75,10 @@ def __init__( @abstractmethod def api_keys(self) -> ApiKeysModule: ... + @property + @abstractmethod + def authorization(self) -> AuthorizationModule: ... + @property @abstractmethod def audit_logs(self) -> AuditLogsModule: ... diff --git a/src/workos/async_client.py b/src/workos/async_client.py index 11cec994..c050865f 100644 --- a/src/workos/async_client.py +++ b/src/workos/async_client.py @@ -3,6 +3,7 @@ from workos._base_client import BaseClient from workos.api_keys import AsyncApiKeys from workos.audit_logs import AsyncAuditLogs +from workos.authorization import AsyncAuthorization from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents from workos.fga import FGAModule @@ -55,6 +56,12 @@ def api_keys(self) -> AsyncApiKeys: self._api_keys = AsyncApiKeys(self._http_client) return self._api_keys + @property + def authorization(self) -> AsyncAuthorization: + if not getattr(self, "_authorization", None): + self._authorization = AsyncAuthorization(self._http_client) + return self._authorization + @property def sso(self) -> AsyncSSO: if not getattr(self, "_sso", None): diff --git a/src/workos/authorization.py b/src/workos/authorization.py new file mode 100644 index 00000000..6e12f035 --- /dev/null +++ b/src/workos/authorization.py @@ -0,0 +1,714 @@ +from typing import Any, Dict, Optional, Protocol, Sequence + +from pydantic import TypeAdapter + +from workos.types.authorization.environment_role import ( + EnvironmentRole, + EnvironmentRoleList, +) +from workos.types.authorization.organization_role import OrganizationRole +from workos.types.authorization.permission import Permission +from workos.types.authorization.role import Role, RoleList +from workos.types.list_resource import ( + ListArgs, + ListMetadata, + ListPage, + WorkOSListResource, +) +from workos.typing.sync_or_async import SyncOrAsync +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.utils.pagination_order import PaginationOrder +from workos.utils.request_helper import ( + DEFAULT_LIST_RESPONSE_LIMIT, + REQUEST_METHOD_DELETE, + REQUEST_METHOD_GET, + REQUEST_METHOD_PATCH, + REQUEST_METHOD_POST, + REQUEST_METHOD_PUT, +) + +AUTHORIZATION_PERMISSIONS_PATH = "authorization/permissions" + +_role_adapter: TypeAdapter[Role] = TypeAdapter(Role) + + +class PermissionListFilters(ListArgs, total=False): + pass + + +PermissionsListResource = WorkOSListResource[ + Permission, PermissionListFilters, ListMetadata +] + + +class AuthorizationModule(Protocol): + """Offers methods through the WorkOS Authorization service.""" + + def create_permission( + self, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> SyncOrAsync[Permission]: ... + + def list_permissions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[PermissionsListResource]: ... + + def get_permission(self, slug: str) -> SyncOrAsync[Permission]: ... + + def update_permission( + self, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> SyncOrAsync[Permission]: ... + + def delete_permission(self, slug: str) -> SyncOrAsync[None]: ... + + # Organization Roles + + def create_organization_role( + self, + organization_id: str, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> SyncOrAsync[OrganizationRole]: ... + + def list_organization_roles( + self, organization_id: str + ) -> SyncOrAsync[RoleList]: ... + + def get_organization_role( + self, organization_id: str, slug: str + ) -> SyncOrAsync[Role]: ... + + def update_organization_role( + self, + organization_id: str, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> SyncOrAsync[OrganizationRole]: ... + + def set_organization_role_permissions( + self, + organization_id: str, + slug: str, + *, + permissions: Sequence[str], + ) -> SyncOrAsync[OrganizationRole]: ... + + def add_organization_role_permission( + self, + organization_id: str, + slug: str, + *, + permission_slug: str, + ) -> SyncOrAsync[OrganizationRole]: ... + + def remove_organization_role_permission( + self, + organization_id: str, + slug: str, + *, + permission_slug: str, + ) -> SyncOrAsync[None]: ... + + # Environment Roles + + def create_environment_role( + self, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> SyncOrAsync[EnvironmentRole]: ... + + def list_environment_roles(self) -> SyncOrAsync[EnvironmentRoleList]: ... + + def get_environment_role(self, slug: str) -> SyncOrAsync[EnvironmentRole]: ... + + def update_environment_role( + self, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> SyncOrAsync[EnvironmentRole]: ... + + def set_environment_role_permissions( + self, + slug: str, + *, + permissions: Sequence[str], + ) -> SyncOrAsync[EnvironmentRole]: ... + + def add_environment_role_permission( + self, + slug: str, + *, + permission_slug: str, + ) -> SyncOrAsync[EnvironmentRole]: ... + + +class Authorization(AuthorizationModule): + _http_client: SyncHTTPClient + + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client + + def create_permission( + self, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> Permission: + json: Dict[str, Any] = {"slug": slug, "name": name} + if description is not None: + json["description"] = description + + response = self._http_client.request( + AUTHORIZATION_PERMISSIONS_PATH, + method=REQUEST_METHOD_POST, + json=json, + ) + + return Permission.model_validate(response) + + def list_permissions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> PermissionsListResource: + list_params: PermissionListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + AUTHORIZATION_PERMISSIONS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[Permission, PermissionListFilters, ListMetadata]( + list_method=self.list_permissions, + list_args=list_params, + **ListPage[Permission](**response).model_dump(), + ) + + def get_permission(self, slug: str) -> Permission: + response = self._http_client.request( + f"{AUTHORIZATION_PERMISSIONS_PATH}/{slug}", + method=REQUEST_METHOD_GET, + ) + + return Permission.model_validate(response) + + def update_permission( + self, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> Permission: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if description is not None: + json["description"] = description + + response = self._http_client.request( + f"{AUTHORIZATION_PERMISSIONS_PATH}/{slug}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return Permission.model_validate(response) + + def delete_permission(self, slug: str) -> None: + self._http_client.request( + f"{AUTHORIZATION_PERMISSIONS_PATH}/{slug}", + method=REQUEST_METHOD_DELETE, + ) + + # Organization Roles + + def create_organization_role( + self, + organization_id: str, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> OrganizationRole: + json: Dict[str, Any] = {"slug": slug, "name": name} + if description is not None: + json["description"] = description + + response = self._http_client.request( + f"authorization/organizations/{organization_id}/roles", + method=REQUEST_METHOD_POST, + json=json, + ) + + return OrganizationRole.model_validate(response) + + def list_organization_roles(self, organization_id: str) -> RoleList: + response = self._http_client.request( + f"authorization/organizations/{organization_id}/roles", + method=REQUEST_METHOD_GET, + ) + + return RoleList.model_validate(response) + + def get_organization_role(self, organization_id: str, slug: str) -> Role: + response = self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}", + method=REQUEST_METHOD_GET, + ) + + return _role_adapter.validate_python(response) + + def update_organization_role( + self, + organization_id: str, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> OrganizationRole: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if description is not None: + json["description"] = description + + response = self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return OrganizationRole.model_validate(response) + + def set_organization_role_permissions( + self, + organization_id: str, + slug: str, + *, + permissions: Sequence[str], + ) -> OrganizationRole: + response = self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}/permissions", + method=REQUEST_METHOD_PUT, + json={"permissions": list(permissions)}, + ) + + return OrganizationRole.model_validate(response) + + def add_organization_role_permission( + self, + organization_id: str, + slug: str, + *, + permission_slug: str, + ) -> OrganizationRole: + response = self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}/permissions", + method=REQUEST_METHOD_POST, + json={"slug": permission_slug}, + ) + + return OrganizationRole.model_validate(response) + + def remove_organization_role_permission( + self, + organization_id: str, + slug: str, + *, + permission_slug: str, + ) -> None: + self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}/permissions/{permission_slug}", + method=REQUEST_METHOD_DELETE, + ) + + # Environment Roles + + def create_environment_role( + self, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> EnvironmentRole: + json: Dict[str, Any] = {"slug": slug, "name": name} + if description is not None: + json["description"] = description + + response = self._http_client.request( + "authorization/roles", + method=REQUEST_METHOD_POST, + json=json, + ) + + return EnvironmentRole.model_validate(response) + + def list_environment_roles(self) -> EnvironmentRoleList: + response = self._http_client.request( + "authorization/roles", + method=REQUEST_METHOD_GET, + ) + + return EnvironmentRoleList.model_validate(response) + + def get_environment_role(self, slug: str) -> EnvironmentRole: + response = self._http_client.request( + f"authorization/roles/{slug}", + method=REQUEST_METHOD_GET, + ) + + return EnvironmentRole.model_validate(response) + + def update_environment_role( + self, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> EnvironmentRole: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if description is not None: + json["description"] = description + + response = self._http_client.request( + f"authorization/roles/{slug}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return EnvironmentRole.model_validate(response) + + def set_environment_role_permissions( + self, + slug: str, + *, + permissions: Sequence[str], + ) -> EnvironmentRole: + response = self._http_client.request( + f"authorization/roles/{slug}/permissions", + method=REQUEST_METHOD_PUT, + json={"permissions": list(permissions)}, + ) + + return EnvironmentRole.model_validate(response) + + def add_environment_role_permission( + self, + slug: str, + *, + permission_slug: str, + ) -> EnvironmentRole: + response = self._http_client.request( + f"authorization/roles/{slug}/permissions", + method=REQUEST_METHOD_POST, + json={"slug": permission_slug}, + ) + + return EnvironmentRole.model_validate(response) + + +class AsyncAuthorization(AuthorizationModule): + _http_client: AsyncHTTPClient + + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def create_permission( + self, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> Permission: + json: Dict[str, Any] = {"slug": slug, "name": name} + if description is not None: + json["description"] = description + + response = await self._http_client.request( + AUTHORIZATION_PERMISSIONS_PATH, + method=REQUEST_METHOD_POST, + json=json, + ) + + return Permission.model_validate(response) + + async def list_permissions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> PermissionsListResource: + list_params: PermissionListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + AUTHORIZATION_PERMISSIONS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[Permission, PermissionListFilters, ListMetadata]( + list_method=self.list_permissions, + list_args=list_params, + **ListPage[Permission](**response).model_dump(), + ) + + async def get_permission(self, slug: str) -> Permission: + response = await self._http_client.request( + f"{AUTHORIZATION_PERMISSIONS_PATH}/{slug}", + method=REQUEST_METHOD_GET, + ) + + return Permission.model_validate(response) + + async def update_permission( + self, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> Permission: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if description is not None: + json["description"] = description + + response = await self._http_client.request( + f"{AUTHORIZATION_PERMISSIONS_PATH}/{slug}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return Permission.model_validate(response) + + async def delete_permission(self, slug: str) -> None: + await self._http_client.request( + f"{AUTHORIZATION_PERMISSIONS_PATH}/{slug}", + method=REQUEST_METHOD_DELETE, + ) + + # Organization Roles + + async def create_organization_role( + self, + organization_id: str, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> OrganizationRole: + json: Dict[str, Any] = {"slug": slug, "name": name} + if description is not None: + json["description"] = description + + response = await self._http_client.request( + f"authorization/organizations/{organization_id}/roles", + method=REQUEST_METHOD_POST, + json=json, + ) + + return OrganizationRole.model_validate(response) + + async def list_organization_roles(self, organization_id: str) -> RoleList: + response = await self._http_client.request( + f"authorization/organizations/{organization_id}/roles", + method=REQUEST_METHOD_GET, + ) + + return RoleList.model_validate(response) + + async def get_organization_role(self, organization_id: str, slug: str) -> Role: + response = await self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}", + method=REQUEST_METHOD_GET, + ) + + return _role_adapter.validate_python(response) + + async def update_organization_role( + self, + organization_id: str, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> OrganizationRole: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if description is not None: + json["description"] = description + + response = await self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return OrganizationRole.model_validate(response) + + async def set_organization_role_permissions( + self, + organization_id: str, + slug: str, + *, + permissions: Sequence[str], + ) -> OrganizationRole: + response = await self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}/permissions", + method=REQUEST_METHOD_PUT, + json={"permissions": list(permissions)}, + ) + + return OrganizationRole.model_validate(response) + + async def add_organization_role_permission( + self, + organization_id: str, + slug: str, + *, + permission_slug: str, + ) -> OrganizationRole: + response = await self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}/permissions", + method=REQUEST_METHOD_POST, + json={"slug": permission_slug}, + ) + + return OrganizationRole.model_validate(response) + + async def remove_organization_role_permission( + self, + organization_id: str, + slug: str, + *, + permission_slug: str, + ) -> None: + await self._http_client.request( + f"authorization/organizations/{organization_id}/roles/{slug}/permissions/{permission_slug}", + method=REQUEST_METHOD_DELETE, + ) + + # Environment Roles + + async def create_environment_role( + self, + *, + slug: str, + name: str, + description: Optional[str] = None, + ) -> EnvironmentRole: + json: Dict[str, Any] = {"slug": slug, "name": name} + if description is not None: + json["description"] = description + + response = await self._http_client.request( + "authorization/roles", + method=REQUEST_METHOD_POST, + json=json, + ) + + return EnvironmentRole.model_validate(response) + + async def list_environment_roles(self) -> EnvironmentRoleList: + response = await self._http_client.request( + "authorization/roles", + method=REQUEST_METHOD_GET, + ) + + return EnvironmentRoleList.model_validate(response) + + async def get_environment_role(self, slug: str) -> EnvironmentRole: + response = await self._http_client.request( + f"authorization/roles/{slug}", + method=REQUEST_METHOD_GET, + ) + + return EnvironmentRole.model_validate(response) + + async def update_environment_role( + self, + slug: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> EnvironmentRole: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if description is not None: + json["description"] = description + + response = await self._http_client.request( + f"authorization/roles/{slug}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return EnvironmentRole.model_validate(response) + + async def set_environment_role_permissions( + self, + slug: str, + *, + permissions: Sequence[str], + ) -> EnvironmentRole: + response = await self._http_client.request( + f"authorization/roles/{slug}/permissions", + method=REQUEST_METHOD_PUT, + json={"permissions": list(permissions)}, + ) + + return EnvironmentRole.model_validate(response) + + async def add_environment_role_permission( + self, + slug: str, + *, + permission_slug: str, + ) -> EnvironmentRole: + response = await self._http_client.request( + f"authorization/roles/{slug}/permissions", + method=REQUEST_METHOD_POST, + json={"slug": permission_slug}, + ) + + return EnvironmentRole.model_validate(response) diff --git a/src/workos/client.py b/src/workos/client.py index d7da65d6..68b9a287 100644 --- a/src/workos/client.py +++ b/src/workos/client.py @@ -3,6 +3,7 @@ from workos._base_client import BaseClient from workos.api_keys import ApiKeys from workos.audit_logs import AuditLogs +from workos.authorization import Authorization from workos.directory_sync import DirectorySync from workos.fga import FGA from workos.organizations import Organizations @@ -55,6 +56,12 @@ def api_keys(self) -> ApiKeys: self._api_keys = ApiKeys(self._http_client) return self._api_keys + @property + def authorization(self) -> Authorization: + if not getattr(self, "_authorization", None): + self._authorization = Authorization(self._http_client) + return self._authorization + @property def sso(self) -> SSO: if not getattr(self, "_sso", None): diff --git a/src/workos/types/authorization/__init__.py b/src/workos/types/authorization/__init__.py new file mode 100644 index 00000000..609a4f2d --- /dev/null +++ b/src/workos/types/authorization/__init__.py @@ -0,0 +1,14 @@ +from workos.types.authorization.environment_role import ( + EnvironmentRole, + EnvironmentRoleList, +) +from workos.types.authorization.organization_role import ( + OrganizationRole, + OrganizationRoleEvent, + OrganizationRoleList, +) +from workos.types.authorization.permission import Permission +from workos.types.authorization.role import ( + Role, + RoleList, +) diff --git a/src/workos/types/events/organization_role_payload.py b/src/workos/types/authorization/environment_role.py similarity index 51% rename from src/workos/types/events/organization_role_payload.py rename to src/workos/types/authorization/environment_role.py index e257baff..a73d8ed5 100644 --- a/src/workos/types/events/organization_role_payload.py +++ b/src/workos/types/authorization/environment_role.py @@ -1,14 +1,20 @@ from typing import Literal, Optional, Sequence + from workos.types.workos_model import WorkOSModel -class OrganizationRolePayload(WorkOSModel): - object: Literal["organization_role"] - organization_id: str - slug: str +class EnvironmentRole(WorkOSModel): + object: Literal["role"] + id: str name: str + slug: str description: Optional[str] = None - resource_type_slug: str permissions: Sequence[str] + type: Literal["EnvironmentRole"] created_at: str updated_at: str + + +class EnvironmentRoleList(WorkOSModel): + object: Literal["list"] + data: Sequence[EnvironmentRole] diff --git a/src/workos/types/authorization/organization_role.py b/src/workos/types/authorization/organization_role.py new file mode 100644 index 00000000..2e343ad6 --- /dev/null +++ b/src/workos/types/authorization/organization_role.py @@ -0,0 +1,34 @@ +from typing import Literal, Optional, Sequence + +from workos.types.workos_model import WorkOSModel + + +class OrganizationRole(WorkOSModel): + object: Literal["role"] + id: str + name: str + slug: str + description: Optional[str] = None + permissions: Sequence[str] + type: Literal["OrganizationRole"] + created_at: str + updated_at: str + + +class OrganizationRoleEvent(WorkOSModel): + """Organization role type for Events API responses.""" + + object: Literal["organization_role"] + organization_id: str + slug: str + name: str + description: Optional[str] = None + resource_type_slug: str + permissions: Sequence[str] + created_at: str + updated_at: str + + +class OrganizationRoleList(WorkOSModel): + object: Literal["list"] + data: Sequence[OrganizationRole] diff --git a/src/workos/types/events/permission_payload.py b/src/workos/types/authorization/permission.py similarity index 87% rename from src/workos/types/events/permission_payload.py rename to src/workos/types/authorization/permission.py index 514af392..13f9d7a8 100644 --- a/src/workos/types/events/permission_payload.py +++ b/src/workos/types/authorization/permission.py @@ -1,8 +1,9 @@ from typing import Literal, Optional + from workos.types.workos_model import WorkOSModel -class PermissionPayload(WorkOSModel): +class Permission(WorkOSModel): object: Literal["permission"] id: str slug: str diff --git a/src/workos/types/authorization/role.py b/src/workos/types/authorization/role.py new file mode 100644 index 00000000..7ea6f747 --- /dev/null +++ b/src/workos/types/authorization/role.py @@ -0,0 +1,18 @@ +from typing import Literal, Sequence, Union + +from pydantic import Field +from typing_extensions import Annotated + +from workos.types.authorization.environment_role import EnvironmentRole +from workos.types.authorization.organization_role import OrganizationRole +from workos.types.workos_model import WorkOSModel + +Role = Annotated[ + Union[EnvironmentRole, OrganizationRole], + Field(discriminator="type"), +] + + +class RoleList(WorkOSModel): + object: Literal["list"] + data: Sequence[Role] diff --git a/src/workos/types/events/__init__.py b/src/workos/types/events/__init__.py index 25a293c1..004d6077 100644 --- a/src/workos/types/events/__init__.py +++ b/src/workos/types/events/__init__.py @@ -11,7 +11,5 @@ from .event import * from .flag_payload import * from .organization_domain_verification_failed_payload import * -from .organization_role_payload import * -from .permission_payload import * from .previous_attributes import * from .session_payload import * diff --git a/src/workos/types/events/event.py b/src/workos/types/events/event.py index 582be909..028e08f3 100644 --- a/src/workos/types/events/event.py +++ b/src/workos/types/events/event.py @@ -42,13 +42,13 @@ from workos.types.events.directory_user_with_previous_attributes import ( DirectoryUserWithPreviousAttributes, ) +from workos.types.authorization.organization_role import OrganizationRoleEvent +from workos.types.authorization.permission import Permission from workos.types.events.event_model import EventModel from workos.types.events.flag_payload import FlagPayload, FlagRuleUpdatedContext from workos.types.events.organization_domain_verification_failed_payload import ( OrganizationDomainVerificationFailedPayload, ) -from workos.types.events.organization_role_payload import OrganizationRolePayload -from workos.types.events.permission_payload import PermissionPayload from workos.types.events.session_payload import ( SessionCreatedPayload, SessionRevokedPayload, @@ -308,16 +308,16 @@ class OrganizationMembershipUpdatedEvent(EventModel[OrganizationMembership]): event: Literal["organization_membership.updated"] -class OrganizationRoleCreatedEvent(EventModel[OrganizationRolePayload]): +class OrganizationRoleCreatedEvent(EventModel[OrganizationRoleEvent]): event: Literal["organization_role.created"] -class OrganizationRoleDeletedEvent(EventModel[OrganizationRolePayload]): - event: Literal["organization_role.deleted"] +class OrganizationRoleUpdatedEvent(EventModel[OrganizationRoleEvent]): + event: Literal["organization_role.updated"] -class OrganizationRoleUpdatedEvent(EventModel[OrganizationRolePayload]): - event: Literal["organization_role.updated"] +class OrganizationRoleDeletedEvent(EventModel[OrganizationRoleEvent]): + event: Literal["organization_role.deleted"] class PasswordResetCreatedEvent(EventModel[PasswordResetCommon]): @@ -328,16 +328,16 @@ class PasswordResetSucceededEvent(EventModel[PasswordResetCommon]): event: Literal["password_reset.succeeded"] -class PermissionCreatedEvent(EventModel[PermissionPayload]): +class PermissionCreatedEvent(EventModel[Permission]): event: Literal["permission.created"] -class PermissionDeletedEvent(EventModel[PermissionPayload]): - event: Literal["permission.deleted"] +class PermissionUpdatedEvent(EventModel[Permission]): + event: Literal["permission.updated"] -class PermissionUpdatedEvent(EventModel[PermissionPayload]): - event: Literal["permission.updated"] +class PermissionDeletedEvent(EventModel[Permission]): + event: Literal["permission.deleted"] class RoleCreatedEvent(EventModel[EventRole]): @@ -428,13 +428,13 @@ class UserUpdatedEvent(EventModel[User]): OrganizationMembershipDeletedEvent, OrganizationMembershipUpdatedEvent, OrganizationRoleCreatedEvent, - OrganizationRoleDeletedEvent, OrganizationRoleUpdatedEvent, + OrganizationRoleDeletedEvent, PasswordResetCreatedEvent, PasswordResetSucceededEvent, PermissionCreatedEvent, - PermissionDeletedEvent, PermissionUpdatedEvent, + PermissionDeletedEvent, RoleCreatedEvent, RoleDeletedEvent, RoleUpdatedEvent, diff --git a/src/workos/types/events/event_model.py b/src/workos/types/events/event_model.py index d010e004..2d4fb4dc 100644 --- a/src/workos/types/events/event_model.py +++ b/src/workos/types/events/event_model.py @@ -46,15 +46,14 @@ from workos.types.events.organization_domain_verification_failed_payload import ( OrganizationDomainVerificationFailedPayload, ) -from workos.types.events.organization_role_payload import OrganizationRolePayload -from workos.types.events.permission_payload import PermissionPayload - from workos.types.events.session_payload import ( SessionCreatedPayload, SessionRevokedPayload, ) from workos.types.organizations.organization_common import OrganizationCommon from workos.types.organization_domains import OrganizationDomain +from workos.types.authorization.organization_role import OrganizationRoleEvent +from workos.types.authorization.permission import Permission from workos.types.roles.role import EventRole from workos.types.sso.connection import Connection from workos.types.user_management.email_verification import ( @@ -105,9 +104,9 @@ OrganizationDomain, OrganizationDomainVerificationFailedPayload, OrganizationMembership, - OrganizationRolePayload, + OrganizationRoleEvent, PasswordResetCommon, - PermissionPayload, + Permission, SessionCreatedPayload, SessionRevokedPayload, User, diff --git a/src/workos/types/events/event_type.py b/src/workos/types/events/event_type.py index ca6f9447..6ccc5d57 100644 --- a/src/workos/types/events/event_type.py +++ b/src/workos/types/events/event_type.py @@ -59,13 +59,13 @@ "organization_membership.deleted", "organization_membership.updated", "organization_role.created", - "organization_role.deleted", "organization_role.updated", + "organization_role.deleted", "password_reset.created", "password_reset.succeeded", "permission.created", - "permission.deleted", "permission.updated", + "permission.deleted", "role.created", "role.deleted", "role.updated", diff --git a/src/workos/types/list_resource.py b/src/workos/types/list_resource.py index 14cbb600..e8621ab9 100644 --- a/src/workos/types/list_resource.py +++ b/src/workos/types/list_resource.py @@ -19,6 +19,7 @@ from typing_extensions import Required, TypedDict from workos.types.api_keys import ApiKey from workos.types.audit_logs import AuditLogAction, AuditLogSchema +from workos.types.authorization.permission import Permission from workos.types.directory_sync import ( Directory, DirectoryGroup, @@ -57,6 +58,7 @@ Invitation, Organization, OrganizationMembership, + Permission, AuthorizationResource, AuthorizationResourceType, User, diff --git a/src/workos/types/webhooks/webhook.py b/src/workos/types/webhooks/webhook.py index fa2a47b0..a7b42823 100644 --- a/src/workos/types/webhooks/webhook.py +++ b/src/workos/types/webhooks/webhook.py @@ -47,12 +47,12 @@ from workos.types.events.organization_domain_verification_failed_payload import ( OrganizationDomainVerificationFailedPayload, ) -from workos.types.events.organization_role_payload import OrganizationRolePayload -from workos.types.events.permission_payload import PermissionPayload from workos.types.events.session_payload import ( SessionCreatedPayload, SessionRevokedPayload, ) +from workos.types.authorization.organization_role import OrganizationRoleEvent +from workos.types.authorization.permission import Permission from workos.types.organization_domains import OrganizationDomain from workos.types.organizations.organization_common import OrganizationCommon from workos.types.roles.role import EventRole @@ -316,16 +316,16 @@ class OrganizationMembershipUpdatedWebhook(WebhookModel[OrganizationMembership]) event: Literal["organization_membership.updated"] -class OrganizationRoleCreatedWebhook(WebhookModel[OrganizationRolePayload]): +class OrganizationRoleCreatedWebhook(WebhookModel[OrganizationRoleEvent]): event: Literal["organization_role.created"] -class OrganizationRoleDeletedWebhook(WebhookModel[OrganizationRolePayload]): - event: Literal["organization_role.deleted"] +class OrganizationRoleUpdatedWebhook(WebhookModel[OrganizationRoleEvent]): + event: Literal["organization_role.updated"] -class OrganizationRoleUpdatedWebhook(WebhookModel[OrganizationRolePayload]): - event: Literal["organization_role.updated"] +class OrganizationRoleDeletedWebhook(WebhookModel[OrganizationRoleEvent]): + event: Literal["organization_role.deleted"] class PasswordResetCreatedWebhook(WebhookModel[PasswordResetCommon]): @@ -336,16 +336,16 @@ class PasswordResetSucceededWebhook(WebhookModel[PasswordResetCommon]): event: Literal["password_reset.succeeded"] -class PermissionCreatedWebhook(WebhookModel[PermissionPayload]): +class PermissionCreatedWebhook(WebhookModel[Permission]): event: Literal["permission.created"] -class PermissionDeletedWebhook(WebhookModel[PermissionPayload]): - event: Literal["permission.deleted"] +class PermissionUpdatedWebhook(WebhookModel[Permission]): + event: Literal["permission.updated"] -class PermissionUpdatedWebhook(WebhookModel[PermissionPayload]): - event: Literal["permission.updated"] +class PermissionDeletedWebhook(WebhookModel[Permission]): + event: Literal["permission.deleted"] class RoleCreatedWebhook(WebhookModel[EventRole]): @@ -436,13 +436,13 @@ class UserUpdatedWebhook(WebhookModel[User]): OrganizationMembershipDeletedWebhook, OrganizationMembershipUpdatedWebhook, OrganizationRoleCreatedWebhook, - OrganizationRoleDeletedWebhook, OrganizationRoleUpdatedWebhook, + OrganizationRoleDeletedWebhook, PasswordResetCreatedWebhook, PasswordResetSucceededWebhook, PermissionCreatedWebhook, - PermissionDeletedWebhook, PermissionUpdatedWebhook, + PermissionDeletedWebhook, RoleCreatedWebhook, RoleDeletedWebhook, RoleUpdatedWebhook, diff --git a/src/workos/utils/request_helper.py b/src/workos/utils/request_helper.py index 933b3376..8b240255 100644 --- a/src/workos/utils/request_helper.py +++ b/src/workos/utils/request_helper.py @@ -6,6 +6,7 @@ RESPONSE_TYPE_CODE = "code" REQUEST_METHOD_DELETE = "delete" REQUEST_METHOD_GET = "get" +REQUEST_METHOD_PATCH = "patch" REQUEST_METHOD_POST = "post" REQUEST_METHOD_PUT = "put" diff --git a/tests/test_authorization.py b/tests/test_authorization.py new file mode 100644 index 00000000..cd78a3e2 --- /dev/null +++ b/tests/test_authorization.py @@ -0,0 +1,449 @@ +from typing import Union + +import pytest +from tests.utils.fixtures.mock_environment_role import MockEnvironmentRole +from tests.utils.fixtures.mock_organization_role import MockOrganizationRole +from tests.utils.fixtures.mock_permission import MockPermission +from tests.utils.list_resource import list_response_of +from tests.utils.syncify import syncify +from workos.authorization import AsyncAuthorization, Authorization + + +@pytest.mark.sync_and_async(Authorization, AsyncAuthorization) +class TestAuthorization: + @pytest.fixture(autouse=True) + def setup(self, module_instance: Union[Authorization, AsyncAuthorization]): + self.http_client = module_instance._http_client + self.authorization = module_instance + + @pytest.fixture + def mock_permission(self): + return MockPermission(id="perm_01ABC").dict() + + @pytest.fixture + def mock_permissions(self): + permission_list = [ + MockPermission(id=f"perm_{i}", slug=f"perm-{i}").dict() for i in range(5) + ] + return { + "data": permission_list, + "list_metadata": {"before": None, "after": None}, + "object": "list", + } + + @pytest.fixture + def mock_permissions_multiple_data_pages(self): + permission_list = [ + MockPermission(id=f"perm_{i}", slug=f"perm-{i}").dict() for i in range(40) + ] + return list_response_of(data=permission_list) + + def test_create_permission( + self, mock_permission, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_permission, 201 + ) + + permission = syncify( + self.authorization.create_permission( + slug="documents:read", name="Read Documents" + ) + ) + + assert permission.id == "perm_01ABC" + assert permission.slug == "documents:read" + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith("/authorization/permissions") + assert request_kwargs["json"] == { + "slug": "documents:read", + "name": "Read Documents", + } + + def test_create_permission_with_description( + self, mock_permission, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_permission, 201 + ) + + syncify( + self.authorization.create_permission( + slug="documents:read", + name="Read Documents", + description="Allows reading documents", + ) + ) + + assert request_kwargs["json"] == { + "slug": "documents:read", + "name": "Read Documents", + "description": "Allows reading documents", + } + + def test_list_permissions( + self, mock_permissions, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_permissions, 200 + ) + + permissions_response = syncify(self.authorization.list_permissions()) + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/authorization/permissions") + assert len(permissions_response.data) == 5 + + def test_list_permissions_auto_pagination( + self, + mock_permissions_multiple_data_pages, + test_auto_pagination, + ): + test_auto_pagination( + http_client=self.http_client, + list_function=self.authorization.list_permissions, + expected_all_page_data=mock_permissions_multiple_data_pages["data"], + ) + + def test_get_permission( + self, mock_permission, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_permission, 200 + ) + + permission = syncify(self.authorization.get_permission("documents:read")) + + assert permission.id == "perm_01ABC" + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith( + "/authorization/permissions/documents:read" + ) + + def test_update_permission( + self, mock_permission, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_permission, 200 + ) + + permission = syncify( + self.authorization.update_permission("documents:read", name="Updated Name") + ) + + assert permission.id == "perm_01ABC" + assert request_kwargs["method"] == "patch" + assert request_kwargs["url"].endswith( + "/authorization/permissions/documents:read" + ) + assert request_kwargs["json"] == {"name": "Updated Name"} + + def test_update_permission_with_description( + self, mock_permission, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_permission, 200 + ) + + syncify( + self.authorization.update_permission( + "documents:read", + name="Updated Name", + description="Updated description", + ) + ) + + assert request_kwargs["json"] == { + "name": "Updated Name", + "description": "Updated description", + } + + def test_delete_permission(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, + 202, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = syncify(self.authorization.delete_permission("documents:read")) + + assert response is None + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith( + "/authorization/permissions/documents:read" + ) + + # --- Organization Role fixtures --- + + @pytest.fixture + def mock_organization_role(self): + return MockOrganizationRole(id="role_01ABC").dict() + + @pytest.fixture + def mock_organization_roles(self): + return { + "data": [MockOrganizationRole(id=f"role_{i}").dict() for i in range(5)], + "object": "list", + } + + # --- Organization Role tests --- + + def test_create_organization_role( + self, mock_organization_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_role, 201 + ) + + role = syncify( + self.authorization.create_organization_role( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", + slug="admin", + name="Admin", + ) + ) + + assert role.id == "role_01ABC" + assert role.type == "OrganizationRole" + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles" + ) + assert request_kwargs["json"] == {"slug": "admin", "name": "Admin"} + + def test_list_organization_roles( + self, mock_organization_roles, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_roles, 200 + ) + + roles_response = syncify( + self.authorization.list_organization_roles("org_01EHT88Z8J8795GZNQ4ZP1J81T") + ) + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles" + ) + assert len(roles_response.data) == 5 + + def test_get_organization_role( + self, mock_organization_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_role, 200 + ) + + role = syncify( + self.authorization.get_organization_role( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", "admin" + ) + ) + + assert role.id == "role_01ABC" + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles/admin" + ) + + def test_update_organization_role( + self, mock_organization_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_role, 200 + ) + + role = syncify( + self.authorization.update_organization_role( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "admin", + name="Super Admin", + ) + ) + + assert role.id == "role_01ABC" + assert request_kwargs["method"] == "patch" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles/admin" + ) + assert request_kwargs["json"] == {"name": "Super Admin"} + + def test_set_organization_role_permissions( + self, mock_organization_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_role, 200 + ) + + role = syncify( + self.authorization.set_organization_role_permissions( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "admin", + permissions=["documents:read", "documents:write"], + ) + ) + + assert role.id == "role_01ABC" + assert request_kwargs["method"] == "put" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles/admin/permissions" + ) + assert request_kwargs["json"] == { + "permissions": ["documents:read", "documents:write"] + } + + def test_add_organization_role_permission( + self, mock_organization_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_role, 200 + ) + + role = syncify( + self.authorization.add_organization_role_permission( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "admin", + permission_slug="documents:read", + ) + ) + + assert role.id == "role_01ABC" + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles/admin/permissions" + ) + assert request_kwargs["json"] == {"slug": "documents:read"} + + def test_remove_organization_role_permission( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, + 202, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = syncify( + self.authorization.remove_organization_role_permission( + "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "admin", + permission_slug="documents:read", + ) + ) + + assert response is None + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith( + "/authorization/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles/admin/permissions/documents:read" + ) + + # --- Environment Role fixtures --- + + @pytest.fixture + def mock_environment_role(self): + return MockEnvironmentRole(id="role_01DEF").dict() + + @pytest.fixture + def mock_environment_roles(self): + return { + "data": [MockEnvironmentRole(id=f"role_{i}").dict() for i in range(5)], + "object": "list", + } + + # --- Environment Role tests --- + + def test_create_environment_role( + self, mock_environment_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_environment_role, 201 + ) + + role = syncify( + self.authorization.create_environment_role(slug="member", name="Member") + ) + + assert role.id == "role_01DEF" + assert role.type == "EnvironmentRole" + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith("/authorization/roles") + assert request_kwargs["json"] == {"slug": "member", "name": "Member"} + + def test_list_environment_roles( + self, mock_environment_roles, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_environment_roles, 200 + ) + + roles_response = syncify(self.authorization.list_environment_roles()) + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/authorization/roles") + assert len(roles_response.data) == 5 + + def test_get_environment_role( + self, mock_environment_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_environment_role, 200 + ) + + role = syncify(self.authorization.get_environment_role("member")) + + assert role.id == "role_01DEF" + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/authorization/roles/member") + + def test_update_environment_role( + self, mock_environment_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_environment_role, 200 + ) + + role = syncify( + self.authorization.update_environment_role("member", name="Updated Member") + ) + + assert role.id == "role_01DEF" + assert request_kwargs["method"] == "patch" + assert request_kwargs["url"].endswith("/authorization/roles/member") + assert request_kwargs["json"] == {"name": "Updated Member"} + + def test_set_environment_role_permissions( + self, mock_environment_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_environment_role, 200 + ) + + role = syncify( + self.authorization.set_environment_role_permissions( + "member", permissions=["documents:read"] + ) + ) + + assert role.id == "role_01DEF" + assert request_kwargs["method"] == "put" + assert request_kwargs["url"].endswith("/authorization/roles/member/permissions") + assert request_kwargs["json"] == {"permissions": ["documents:read"]} + + def test_add_environment_role_permission( + self, mock_environment_role, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_environment_role, 200 + ) + + role = syncify( + self.authorization.add_environment_role_permission( + "member", permission_slug="documents:read" + ) + ) + + assert role.id == "role_01DEF" + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith("/authorization/roles/member/permissions") + assert request_kwargs["json"] == {"slug": "documents:read"} diff --git a/tests/utils/fixtures/mock_environment_role.py b/tests/utils/fixtures/mock_environment_role.py new file mode 100644 index 00000000..6063f3ff --- /dev/null +++ b/tests/utils/fixtures/mock_environment_role.py @@ -0,0 +1,19 @@ +import datetime + +from workos.types.authorization.environment_role import EnvironmentRole + + +class MockEnvironmentRole(EnvironmentRole): + def __init__(self, id: str): + now = datetime.datetime.now().isoformat() + super().__init__( + object="role", + id=id, + name="Member", + slug="member", + description="Default environment member role", + permissions=["documents:read"], + type="EnvironmentRole", + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_organization_role.py b/tests/utils/fixtures/mock_organization_role.py new file mode 100644 index 00000000..f2b5ac8c --- /dev/null +++ b/tests/utils/fixtures/mock_organization_role.py @@ -0,0 +1,24 @@ +import datetime + +from workos.types.authorization.organization_role import OrganizationRole + + +class MockOrganizationRole(OrganizationRole): + def __init__( + self, + id: str, + organization_id: str = "org_01EHT88Z8J8795GZNQ4ZP1J81T", + ): + now = datetime.datetime.now().isoformat() + super().__init__( + object="role", + id=id, + organization_id=organization_id, + name="Admin", + slug="admin", + description="Organization admin role", + permissions=["documents:read", "documents:write"], + type="OrganizationRole", + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_permission.py b/tests/utils/fixtures/mock_permission.py new file mode 100644 index 00000000..b1e639bf --- /dev/null +++ b/tests/utils/fixtures/mock_permission.py @@ -0,0 +1,18 @@ +import datetime + +from workos.types.authorization.permission import Permission + + +class MockPermission(Permission): + def __init__(self, id: str, slug: str = "documents:read"): + now = datetime.datetime.now().isoformat() + super().__init__( + object="permission", + id=id, + slug=slug, + name="Read Documents", + description="Allows reading documents", + system=False, + created_at=now, + updated_at=now, + )