diff --git a/src/workos/authorization.py b/src/workos/authorization.py index 6e12f035..d0572a69 100644 --- a/src/workos/authorization.py +++ b/src/workos/authorization.py @@ -1,13 +1,18 @@ -from typing import Any, Dict, Optional, Protocol, Sequence +from enum import Enum +from typing import Any, Dict, Optional, Protocol, Sequence, Union from pydantic import TypeAdapter +from typing_extensions import TypedDict +from workos.types.authorization.access_evaluation import AccessEvaluation 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.resource_identifier import ResourceIdentifier +from workos.types.authorization.resource import Resource from workos.types.authorization.role import Role, RoleList from workos.types.list_resource import ( ListArgs, @@ -27,7 +32,40 @@ REQUEST_METHOD_PUT, ) + +class _Unset(Enum): + TOKEN = 0 + + +UNSET: _Unset = _Unset.TOKEN + AUTHORIZATION_PERMISSIONS_PATH = "authorization/permissions" +AUTHORIZATION_RESOURCES_PATH = "authorization/resources" +AUTHORIZATION_ORGANIZATIONS_PATH = "authorization/organizations" + + +class ResourceListFilters(ListArgs, total=False): + organization_id: Optional[str] + resource_type_slug: Optional[str] + parent_resource_id: Optional[str] + parent_resource_type_slug: Optional[str] + parent_external_id: Optional[str] + search: Optional[str] + + +ResourcesListResource = WorkOSListResource[Resource, ResourceListFilters, ListMetadata] + + +class ParentResourceById(TypedDict): + parent_resource_id: str + + +class ParentResourceByExternalId(TypedDict): + parent_resource_external_id: str + parent_resource_type_slug: str + + +ParentResource = Union[ParentResourceById, ParentResourceByExternalId] _role_adapter: TypeAdapter[Role] = TypeAdapter(Role) @@ -161,6 +199,85 @@ def add_environment_role_permission( permission_slug: str, ) -> SyncOrAsync[EnvironmentRole]: ... + # Resources + + def get_resource(self, resource_id: str) -> SyncOrAsync[Resource]: ... + + def create_resource( + self, + *, + resource_type_slug: str, + organization_id: str, + external_id: str, + name: str, + parent: Optional[ParentResource] = None, + description: Optional[str] = None, + ) -> SyncOrAsync[Resource]: ... + + def update_resource( + self, + resource_id: str, + *, + name: Optional[str] = None, + description: Union[str, None, _Unset] = UNSET, + ) -> SyncOrAsync[Resource]: ... + + def delete_resource( + self, + resource_id: str, + *, + cascade_delete: Optional[bool] = None, + ) -> SyncOrAsync[None]: ... + + def list_resources( + self, + *, + organization_id: Optional[str] = None, + resource_type_slug: Optional[str] = None, + parent_resource_id: Optional[str] = None, + parent_resource_type_slug: Optional[str] = None, + parent_external_id: Optional[str] = None, + search: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[ResourcesListResource]: ... + + def get_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + ) -> SyncOrAsync[Resource]: ... + + def update_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> SyncOrAsync[Resource]: ... + + def delete_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + *, + cascade_delete: Optional[bool] = None, + ) -> SyncOrAsync[None]: ... + + def check( + self, + organization_membership_id: str, + *, + permission_slug: str, + resource: ResourceIdentifier, + ) -> SyncOrAsync[AccessEvaluation]: ... + class Authorization(AuthorizationModule): _http_client: SyncHTTPClient @@ -437,6 +554,202 @@ def add_environment_role_permission( return EnvironmentRole.model_validate(response) + # Resources + + def get_resource(self, resource_id: str) -> Resource: + response = self._http_client.request( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + method=REQUEST_METHOD_GET, + ) + + return Resource.model_validate(response) + + def create_resource( + self, + *, + resource_type_slug: str, + organization_id: str, + external_id: str, + name: str, + parent: Optional[ParentResource] = None, + description: Optional[str] = None, + ) -> Resource: + json: Dict[str, Any] = { + "resource_type_slug": resource_type_slug, + "organization_id": organization_id, + "external_id": external_id, + "name": name, + } + if parent is not None: + json.update(parent) + if description is not None: + json["description"] = description + + response = self._http_client.request( + AUTHORIZATION_RESOURCES_PATH, + method=REQUEST_METHOD_POST, + json=json, + ) + + return Resource.model_validate(response) + + def update_resource( + self, + resource_id: str, + *, + name: Optional[str] = None, + description: Union[str, None, _Unset] = UNSET, + ) -> Resource: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if not isinstance(description, _Unset): + json["description"] = description + + response = self._http_client.request( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + method=REQUEST_METHOD_PATCH, + json=json, + exclude_none=False, + ) + + return Resource.model_validate(response) + + def delete_resource( + self, + resource_id: str, + *, + cascade_delete: Optional[bool] = None, + ) -> None: + if cascade_delete is not None: + self._http_client.delete_with_body( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + json={"cascade_delete": cascade_delete}, + ) + else: + self._http_client.request( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + method=REQUEST_METHOD_DELETE, + ) + + def list_resources( + self, + *, + organization_id: Optional[str] = None, + resource_type_slug: Optional[str] = None, + parent_resource_id: Optional[str] = None, + parent_resource_type_slug: Optional[str] = None, + parent_external_id: Optional[str] = None, + search: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> ResourcesListResource: + list_params: ResourceListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + if organization_id is not None: + list_params["organization_id"] = organization_id + if resource_type_slug is not None: + list_params["resource_type_slug"] = resource_type_slug + if parent_resource_id is not None: + list_params["parent_resource_id"] = parent_resource_id + if parent_resource_type_slug is not None: + list_params["parent_resource_type_slug"] = parent_resource_type_slug + if parent_external_id is not None: + list_params["parent_external_id"] = parent_external_id + if search is not None: + list_params["search"] = search + + response = self._http_client.request( + AUTHORIZATION_RESOURCES_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[Resource, ResourceListFilters, ListMetadata]( + list_method=self.list_resources, + list_args=list_params, + **ListPage[Resource](**response).model_dump(), + ) + + def get_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + ) -> Resource: + response = self._http_client.request( + f"{AUTHORIZATION_ORGANIZATIONS_PATH}/{organization_id}/resources/{resource_type}/{external_id}", + method=REQUEST_METHOD_GET, + ) + + return Resource.model_validate(response) + + def update_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> Resource: + 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_PATH}/{organization_id}/resources/{resource_type}/{external_id}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return Resource.model_validate(response) + + def delete_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + *, + cascade_delete: Optional[bool] = None, + ) -> None: + path = f"{AUTHORIZATION_ORGANIZATIONS_PATH}/{organization_id}/resources/{resource_type}/{external_id}" + params: Dict[str, bool] = {} + if cascade_delete is not None: + params["cascade_delete"] = cascade_delete + + self._http_client.request( + path, + method=REQUEST_METHOD_DELETE, + params=params if params else None, + ) + + def check( + self, + organization_membership_id: str, + *, + permission_slug: str, + resource: ResourceIdentifier, + ) -> AccessEvaluation: + json: Dict[str, Any] = {"permission_slug": permission_slug} + json.update(resource) + + response = self._http_client.request( + f"authorization/organization_memberships/{organization_membership_id}/check", + method=REQUEST_METHOD_POST, + json=json, + ) + + return AccessEvaluation.model_validate(response) + class AsyncAuthorization(AuthorizationModule): _http_client: AsyncHTTPClient @@ -712,3 +1025,199 @@ async def add_environment_role_permission( ) return EnvironmentRole.model_validate(response) + + # Resources + + async def get_resource(self, resource_id: str) -> Resource: + response = await self._http_client.request( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + method=REQUEST_METHOD_GET, + ) + + return Resource.model_validate(response) + + async def create_resource( + self, + *, + resource_type_slug: str, + organization_id: str, + external_id: str, + name: str, + parent: Optional[ParentResource] = None, + description: Optional[str] = None, + ) -> Resource: + json: Dict[str, Any] = { + "resource_type_slug": resource_type_slug, + "organization_id": organization_id, + "external_id": external_id, + "name": name, + } + if parent is not None: + json.update(parent) + if description is not None: + json["description"] = description + + response = await self._http_client.request( + AUTHORIZATION_RESOURCES_PATH, + method=REQUEST_METHOD_POST, + json=json, + ) + + return Resource.model_validate(response) + + async def update_resource( + self, + resource_id: str, + *, + name: Optional[str] = None, + description: Union[str, None, _Unset] = UNSET, + ) -> Resource: + json: Dict[str, Any] = {} + if name is not None: + json["name"] = name + if not isinstance(description, _Unset): + json["description"] = description + + response = await self._http_client.request( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + method=REQUEST_METHOD_PATCH, + json=json, + exclude_none=False, + ) + + return Resource.model_validate(response) + + async def delete_resource( + self, + resource_id: str, + *, + cascade_delete: Optional[bool] = None, + ) -> None: + if cascade_delete is not None: + await self._http_client.delete_with_body( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + json={"cascade_delete": cascade_delete}, + ) + else: + await self._http_client.request( + f"{AUTHORIZATION_RESOURCES_PATH}/{resource_id}", + method=REQUEST_METHOD_DELETE, + ) + + async def list_resources( + self, + *, + organization_id: Optional[str] = None, + resource_type_slug: Optional[str] = None, + parent_resource_id: Optional[str] = None, + parent_resource_type_slug: Optional[str] = None, + parent_external_id: Optional[str] = None, + search: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> ResourcesListResource: + list_params: ResourceListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + if organization_id is not None: + list_params["organization_id"] = organization_id + if resource_type_slug is not None: + list_params["resource_type_slug"] = resource_type_slug + if parent_resource_id is not None: + list_params["parent_resource_id"] = parent_resource_id + if parent_resource_type_slug is not None: + list_params["parent_resource_type_slug"] = parent_resource_type_slug + if parent_external_id is not None: + list_params["parent_external_id"] = parent_external_id + if search is not None: + list_params["search"] = search + + response = await self._http_client.request( + AUTHORIZATION_RESOURCES_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[Resource, ResourceListFilters, ListMetadata]( + list_method=self.list_resources, + list_args=list_params, + **ListPage[Resource](**response).model_dump(), + ) + + async def get_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + ) -> Resource: + response = await self._http_client.request( + f"{AUTHORIZATION_ORGANIZATIONS_PATH}/{organization_id}/resources/{resource_type}/{external_id}", + method=REQUEST_METHOD_GET, + ) + + return Resource.model_validate(response) + + async def update_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> Resource: + 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_PATH}/{organization_id}/resources/{resource_type}/{external_id}", + method=REQUEST_METHOD_PATCH, + json=json, + ) + + return Resource.model_validate(response) + + async def delete_resource_by_external_id( + self, + organization_id: str, + resource_type: str, + external_id: str, + *, + cascade_delete: Optional[bool] = None, + ) -> None: + path = f"{AUTHORIZATION_ORGANIZATIONS_PATH}/{organization_id}/resources/{resource_type}/{external_id}" + params: Dict[str, bool] = {} + if cascade_delete is not None: + params["cascade_delete"] = cascade_delete + + await self._http_client.request( + path, + method=REQUEST_METHOD_DELETE, + params=params if params else None, + ) + + async def check( + self, + organization_membership_id: str, + *, + permission_slug: str, + resource: ResourceIdentifier, + ) -> AccessEvaluation: + json: Dict[str, Any] = {"permission_slug": permission_slug} + json.update(resource) + + response = await self._http_client.request( + f"authorization/organization_memberships/{organization_membership_id}/check", + method=REQUEST_METHOD_POST, + json=json, + ) + + return AccessEvaluation.model_validate(response) diff --git a/src/workos/types/authorization/__init__.py b/src/workos/types/authorization/__init__.py index 609a4f2d..93946662 100644 --- a/src/workos/types/authorization/__init__.py +++ b/src/workos/types/authorization/__init__.py @@ -1,14 +1,29 @@ +from workos.types.authorization.access_evaluation import AccessEvaluation from workos.types.authorization.environment_role import ( EnvironmentRole, EnvironmentRoleList, ) +from workos.types.authorization.organization_membership import ( + AuthorizationOrganizationMembership, +) from workos.types.authorization.organization_role import ( OrganizationRole, OrganizationRoleEvent, OrganizationRoleList, ) from workos.types.authorization.permission import Permission +from workos.types.authorization.resource import Resource +from workos.types.authorization.resource_identifier import ( + ResourceIdentifier, + ResourceIdentifierByExternalId, + ResourceIdentifierById, +) from workos.types.authorization.role import ( Role, RoleList, ) +from workos.types.authorization.role_assignment import ( + RoleAssignment, + RoleAssignmentResource, + RoleAssignmentRole, +) diff --git a/src/workos/types/authorization/access_evaluation.py b/src/workos/types/authorization/access_evaluation.py new file mode 100644 index 00000000..6b2a22af --- /dev/null +++ b/src/workos/types/authorization/access_evaluation.py @@ -0,0 +1,7 @@ +from workos.types.workos_model import WorkOSModel + + +class AccessEvaluation(WorkOSModel): + """Representation of a WorkOS Authorization access check result.""" + + authorized: bool diff --git a/src/workos/types/authorization/organization_membership.py b/src/workos/types/authorization/organization_membership.py new file mode 100644 index 00000000..a5390bb0 --- /dev/null +++ b/src/workos/types/authorization/organization_membership.py @@ -0,0 +1,27 @@ +from typing import Any, Literal, Mapping, Optional + +from workos.types.user_management.organization_membership_status import ( + OrganizationMembershipStatus, +) +from workos.types.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped + + +class AuthorizationOrganizationMembership(WorkOSModel): + """Representation of an Organization Membership returned by Authorization endpoints. + + This is a separate type from the user_management OrganizationMembership because + authorization endpoints return memberships without the ``role`` field and include + ``organization_name``. Additionally, ``custom_attributes`` is optional here as + authorization endpoints may omit it. + """ + + object: Literal["organization_membership"] + id: str + user_id: str + organization_id: str + organization_name: str + status: LiteralOrUntyped[OrganizationMembershipStatus] + custom_attributes: Optional[Mapping[str, Any]] = None + created_at: str + updated_at: str diff --git a/src/workos/types/authorization/resource.py b/src/workos/types/authorization/resource.py new file mode 100644 index 00000000..917673c4 --- /dev/null +++ b/src/workos/types/authorization/resource.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional + +from workos.types.workos_model import WorkOSModel + + +class Resource(WorkOSModel): + """Representation of an Authorization Resource.""" + + object: Literal["authorization_resource"] + id: str + external_id: str + name: str + description: Optional[str] = None + resource_type_slug: str + organization_id: str + parent_resource_id: Optional[str] = None + created_at: str + updated_at: str diff --git a/src/workos/types/authorization/resource_identifier.py b/src/workos/types/authorization/resource_identifier.py new file mode 100644 index 00000000..081a175d --- /dev/null +++ b/src/workos/types/authorization/resource_identifier.py @@ -0,0 +1,15 @@ +from typing import Union + +from typing_extensions import TypedDict + + +class ResourceIdentifierById(TypedDict): + resource_id: str + + +class ResourceIdentifierByExternalId(TypedDict): + resource_external_id: str + resource_type_slug: str + + +ResourceIdentifier = Union[ResourceIdentifierById, ResourceIdentifierByExternalId] diff --git a/src/workos/types/authorization/role_assignment.py b/src/workos/types/authorization/role_assignment.py new file mode 100644 index 00000000..9ca59936 --- /dev/null +++ b/src/workos/types/authorization/role_assignment.py @@ -0,0 +1,22 @@ +from typing import Literal + +from workos.types.workos_model import WorkOSModel + + +class RoleAssignmentRole(WorkOSModel): + slug: str + + +class RoleAssignmentResource(WorkOSModel): + id: str + external_id: str + resource_type_slug: str + + +class RoleAssignment(WorkOSModel): + object: Literal["role_assignment"] + id: str + role: RoleAssignmentRole + resource: RoleAssignmentResource + created_at: str + updated_at: str diff --git a/src/workos/types/list_resource.py b/src/workos/types/list_resource.py index e8621ab9..db07dcf0 100644 --- a/src/workos/types/list_resource.py +++ b/src/workos/types/list_resource.py @@ -19,7 +19,12 @@ 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.organization_membership import ( + AuthorizationOrganizationMembership, +) from workos.types.authorization.permission import Permission +from workos.types.authorization.resource import Resource +from workos.types.authorization.role_assignment import RoleAssignment from workos.types.directory_sync import ( Directory, DirectoryGroup, @@ -59,6 +64,9 @@ Organization, OrganizationMembership, Permission, + Resource, + RoleAssignment, + AuthorizationOrganizationMembership, AuthorizationResource, AuthorizationResourceType, User, diff --git a/src/workos/types/user_management/list_filters.py b/src/workos/types/user_management/list_filters.py index a3be45ce..99d92905 100644 --- a/src/workos/types/user_management/list_filters.py +++ b/src/workos/types/user_management/list_filters.py @@ -1,6 +1,6 @@ from typing import Optional, Sequence from workos.types.list_resource import ListArgs -from workos.types.user_management.organization_membership import ( +from workos.types.user_management.organization_membership_status import ( OrganizationMembershipStatus, ) diff --git a/src/workos/types/user_management/organization_membership.py b/src/workos/types/user_management/organization_membership.py index 5c7bda0f..df9e5acf 100644 --- a/src/workos/types/user_management/organization_membership.py +++ b/src/workos/types/user_management/organization_membership.py @@ -1,11 +1,12 @@ from typing import Any, Literal, Mapping, Optional, Sequence from typing_extensions import TypedDict +from workos.types.user_management.organization_membership_status import ( + OrganizationMembershipStatus, +) from workos.types.workos_model import WorkOSModel from workos.typing.literals import LiteralOrUntyped -OrganizationMembershipStatus = Literal["active", "inactive", "pending"] - class OrganizationMembershipRole(TypedDict): slug: str diff --git a/src/workos/types/user_management/organization_membership_status.py b/src/workos/types/user_management/organization_membership_status.py new file mode 100644 index 00000000..c79384cf --- /dev/null +++ b/src/workos/types/user_management/organization_membership_status.py @@ -0,0 +1,3 @@ +from typing import Literal + +OrganizationMembershipStatus = Literal["active", "inactive", "pending"] diff --git a/src/workos/utils/_base_http_client.py b/src/workos/utils/_base_http_client.py index 49dcbcf5..402d71c2 100644 --- a/src/workos/utils/_base_http_client.py +++ b/src/workos/utils/_base_http_client.py @@ -123,6 +123,8 @@ def _prepare_request( json: JsonType = None, headers: HeadersType = None, exclude_default_auth_headers: bool = False, + force_include_body: bool = False, + exclude_none: bool = True, ) -> PreparedRequest: """Executes a request against the WorkOS API. @@ -133,7 +135,8 @@ def _prepare_request( method Optional[str]: One of the supported methods as defined by the REQUEST_METHOD_X constants params Optional[dict]: Query params or body payload to be added to the request headers Optional[dict]: Custom headers to be added to the request - token Optional[str]: Bearer token + exclude_default_auth_headers (bool): If True, excludes default auth headers from the request + force_include_body (bool): If True, allows sending a body in a bodyless request (used for DELETE requests) Returns: dict: Response from WorkOS @@ -149,7 +152,7 @@ def _prepare_request( REQUEST_METHOD_GET, ] - if bodyless_http_method and json is not None: + if bodyless_http_method and json is not None and not force_include_body: raise ValueError(f"Cannot send a body with a {parsed_method} request") # Remove any parameters that are None @@ -157,11 +160,11 @@ def _prepare_request( params = {k: v for k, v in params.items() if v is not None} # Remove any body values that are None - if json is not None and isinstance(json, Mapping): + if exclude_none and json is not None and isinstance(json, Mapping): json = {k: v for k, v in json.items() if v is not None} # We'll spread these return values onto the HTTP client request method - if bodyless_http_method: + if bodyless_http_method and not force_include_body: return { "method": parsed_method, "url": url, diff --git a/src/workos/utils/http_client.py b/src/workos/utils/http_client.py index 203c7df0..5c7deac5 100644 --- a/src/workos/utils/http_client.py +++ b/src/workos/utils/http_client.py @@ -14,7 +14,7 @@ ParamsType, ResponseJson, ) -from workos.utils.request_helper import REQUEST_METHOD_GET +from workos.utils.request_helper import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET class SyncHttpxClientWrapper(httpx.Client): @@ -88,6 +88,7 @@ def request( json: JsonType = None, headers: HeadersType = None, exclude_default_auth_headers: bool = False, + exclude_none: bool = True, ) -> ResponseJson: """Executes a request against the WorkOS API. @@ -98,6 +99,7 @@ def request( method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants params (ParamsType): Query params to be added to the request json (JsonType): Body payload to be added to the request + exclude_none (bool): If True, removes None values from the JSON body Returns: ResponseJson: Response from WorkOS @@ -109,6 +111,28 @@ def request( json=json, headers=headers, exclude_default_auth_headers=exclude_default_auth_headers, + exclude_none=exclude_none, + ) + response = self._client.request(**prepared_request_parameters) + return self._handle_response(response) + + def delete_with_body( + self, + path: str, + json: JsonType = None, + params: ParamsType = None, + headers: HeadersType = None, + exclude_default_auth_headers: bool = False, + ) -> ResponseJson: + """Executes a DELETE request with a JSON body against the WorkOS API.""" + prepared_request_parameters = self._prepare_request( + path=path, + method=REQUEST_METHOD_DELETE, + json=json, + params=params, + headers=headers, + exclude_default_auth_headers=exclude_default_auth_headers, + force_include_body=True, ) response = self._client.request(**prepared_request_parameters) return self._handle_response(response) @@ -185,6 +209,7 @@ async def request( json: JsonType = None, headers: HeadersType = None, exclude_default_auth_headers: bool = False, + exclude_none: bool = True, ) -> ResponseJson: """Executes a request against the WorkOS API. @@ -195,6 +220,7 @@ async def request( method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants params (ParamsType): Query params to be added to the request json (JsonType): Body payload to be added to the request + exclude_none (bool): If True, removes None values from the JSON body Returns: ResponseJson: Response from WorkOS @@ -206,6 +232,28 @@ async def request( json=json, headers=headers, exclude_default_auth_headers=exclude_default_auth_headers, + exclude_none=exclude_none, + ) + response = await self._client.request(**prepared_request_parameters) + return self._handle_response(response) + + async def delete_with_body( + self, + path: str, + json: JsonType = None, + params: ParamsType = None, + headers: HeadersType = None, + exclude_default_auth_headers: bool = False, + ) -> ResponseJson: + """Executes a DELETE request with a JSON body against the WorkOS API.""" + prepared_request_parameters = self._prepare_request( + path=path, + method=REQUEST_METHOD_DELETE, + json=json, + params=params, + headers=headers, + exclude_default_auth_headers=exclude_default_auth_headers, + force_include_body=True, ) response = await self._client.request(**prepared_request_parameters) return self._handle_response(response) diff --git a/tests/test_async_http_client.py b/tests/test_async_http_client.py index 633ed71a..c9da72ba 100644 --- a/tests/test_async_http_client.py +++ b/tests/test_async_http_client.py @@ -326,3 +326,40 @@ async def test_request_removes_none_json_values( json={"organization_id": None, "test": "value"}, ) assert request_kwargs["json"] == {"test": "value"} + + async def test_delete_with_body_sends_json( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) + + await self.http_client.delete_with_body( + path="/test", + json={"resource_id": "res_01ABC"}, + ) + + assert request_kwargs["method"] == "delete" + assert request_kwargs["json"] == {"resource_id": "res_01ABC"} + + async def test_delete_with_body_sends_params( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) + + await self.http_client.delete_with_body( + path="/test", + json={"resource_id": "res_01ABC"}, + params={"org_id": "org_01ABC"}, + ) + + assert request_kwargs["params"] == {"org_id": "org_01ABC"} + assert request_kwargs["json"] == {"resource_id": "res_01ABC"} + + async def test_delete_without_body_raises_value_error(self): + with pytest.raises( + ValueError, match="Cannot send a body with a delete request" + ): + await self.http_client.request( + path="/test", + method="delete", + json={"should": "fail"}, + ) diff --git a/tests/test_authorization_check.py b/tests/test_authorization_check.py new file mode 100644 index 00000000..09fad9ba --- /dev/null +++ b/tests/test_authorization_check.py @@ -0,0 +1,127 @@ +from typing import Union + +import pytest +from tests.utils.syncify import syncify +from workos.authorization import AsyncAuthorization, Authorization +from workos.types.authorization.resource_identifier import ( + ResourceIdentifierByExternalId, + ResourceIdentifierById, +) + + +@pytest.mark.sync_and_async(Authorization, AsyncAuthorization) +class TestAuthorizationCheck: + @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_check_authorized(self): + return {"authorized": True} + + @pytest.fixture + def mock_check_unauthorized(self): + return {"authorized": False} + + def test_check_authorized( + self, mock_check_authorized, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_check_authorized, 200 + ) + + result = syncify( + self.authorization.check( + "om_01ABC", + permission_slug="documents:read", + resource=ResourceIdentifierById(resource_id="res_01ABC"), + ) + ) + + assert result.authorized is True + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith( + "/authorization/organization_memberships/om_01ABC/check" + ) + + def test_check_unauthorized( + self, mock_check_unauthorized, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_check_unauthorized, 200 + ) + + result = syncify( + self.authorization.check( + "om_01ABC", + permission_slug="documents:write", + resource=ResourceIdentifierById(resource_id="res_01ABC"), + ) + ) + + assert result.authorized is False + assert request_kwargs["method"] == "post" + + def test_check_with_resource_id( + self, mock_check_authorized, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_check_authorized, 200 + ) + + syncify( + self.authorization.check( + "om_01ABC", + permission_slug="documents:read", + resource=ResourceIdentifierById(resource_id="res_01XYZ"), + ) + ) + + assert request_kwargs["json"] == { + "permission_slug": "documents:read", + "resource_id": "res_01XYZ", + } + + def test_check_with_resource_external_id( + self, mock_check_authorized, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_check_authorized, 200 + ) + + syncify( + self.authorization.check( + "om_01ABC", + permission_slug="documents:read", + resource=ResourceIdentifierByExternalId( + resource_external_id="ext_doc_123", + resource_type_slug="document", + ), + ) + ) + + assert request_kwargs["json"] == { + "permission_slug": "documents:read", + "resource_external_id": "ext_doc_123", + "resource_type_slug": "document", + } + + def test_check_url_construction( + self, mock_check_authorized, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_check_authorized, 200 + ) + + syncify( + self.authorization.check( + "om_01MEMBERSHIP", + permission_slug="admin:access", + resource=ResourceIdentifierById(resource_id="res_01ABC"), + ) + ) + + assert request_kwargs["url"].endswith( + "/authorization/organization_memberships/om_01MEMBERSHIP/check" + ) diff --git a/tests/test_authorization_resource_crud.py b/tests/test_authorization_resource_crud.py new file mode 100644 index 00000000..3ff79a2d --- /dev/null +++ b/tests/test_authorization_resource_crud.py @@ -0,0 +1,257 @@ +from typing import Union + +import pytest +from tests.utils.fixtures.mock_resource import MockResource +from tests.utils.syncify import syncify +from workos.authorization import AsyncAuthorization, Authorization + + +@pytest.mark.sync_and_async(Authorization, AsyncAuthorization) +class TestAuthorizationResourceCRUD: + @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_resource(self): + return MockResource(id="res_01ABC").dict() + + # --- get_resource --- + + def test_get_resource(self, mock_resource, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + resource = syncify(self.authorization.get_resource("res_01ABC")) + + assert resource.id == "res_01ABC" + assert resource.object == "authorization_resource" + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/authorization/resources/res_01ABC") + + # --- create_resource --- + + def test_create_resource_required_fields_only( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 201 + ) + + resource = syncify( + self.authorization.create_resource( + resource_type_slug="document", + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + external_id="ext_123", + name="Test Resource", + parent={"parent_resource_id": "res_01PARENT"}, + ) + ) + + assert resource.id == "res_01ABC" + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith("/authorization/resources") + assert request_kwargs["json"] == { + "resource_type_slug": "document", + "organization_id": "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "external_id": "ext_123", + "name": "Test Resource", + "parent_resource_id": "res_01PARENT", + } + + def test_create_resource_without_parent( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 201 + ) + + resource = syncify( + self.authorization.create_resource( + resource_type_slug="document", + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + external_id="ext_123", + name="Test Resource", + ) + ) + + assert resource.id == "res_01ABC" + assert request_kwargs["method"] == "post" + assert request_kwargs["json"] == { + "resource_type_slug": "document", + "organization_id": "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "external_id": "ext_123", + "name": "Test Resource", + } + + def test_create_resource_with_all_optional_fields( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 201 + ) + + syncify( + self.authorization.create_resource( + resource_type_slug="document", + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + external_id="ext_123", + name="Test Resource", + parent={"parent_resource_id": "res_01PARENT"}, + description="A test document", + ) + ) + + assert request_kwargs["json"] == { + "resource_type_slug": "document", + "organization_id": "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "external_id": "ext_123", + "name": "Test Resource", + "parent_resource_id": "res_01PARENT", + "description": "A test document", + } + + def test_create_resource_with_parent_by_id( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 201 + ) + + syncify( + self.authorization.create_resource( + resource_type_slug="document", + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + external_id="ext_123", + name="Test Resource", + parent={"parent_resource_id": "res_01PARENT"}, + ) + ) + + assert request_kwargs["json"]["parent_resource_id"] == "res_01PARENT" + + def test_create_resource_with_parent_by_external_id( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 201 + ) + + syncify( + self.authorization.create_resource( + resource_type_slug="document", + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T", + external_id="ext_123", + name="Test Resource", + parent={ + "parent_resource_external_id": "ext_parent_456", + "parent_resource_type_slug": "folder", + }, + ) + ) + + assert request_kwargs["json"]["parent_resource_external_id"] == "ext_parent_456" + assert request_kwargs["json"]["parent_resource_type_slug"] == "folder" + + # --- update_resource --- + + def test_update_resource_with_meta( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + resource = syncify( + self.authorization.update_resource( + "res_01ABC", + name="Updated Name", + description="Updated description", + ) + ) + + assert resource.id == "res_01ABC" + assert request_kwargs["method"] == "patch" + assert request_kwargs["url"].endswith("/authorization/resources/res_01ABC") + assert request_kwargs["json"] == { + "name": "Updated Name", + "description": "Updated description", + } + + def test_update_resource_clear_description( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + syncify(self.authorization.update_resource("res_01ABC", description=None)) + + assert request_kwargs["method"] == "patch" + assert request_kwargs["json"] == {"description": None} + + def test_update_resource_without_meta( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + syncify(self.authorization.update_resource("res_01ABC")) + + assert request_kwargs["method"] == "patch" + assert request_kwargs["json"] == {} + + def test_update_resource_without_desc( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + resource = syncify( + self.authorization.update_resource( + "res_01ABC", + name="Updated Name", + ) + ) + + assert resource.id == "res_01ABC" + assert request_kwargs["method"] == "patch" + assert request_kwargs["url"].endswith("/authorization/resources/res_01ABC") + assert request_kwargs["json"] == {"name": "Updated Name"} + + # --- delete_resource --- + + def test_delete_resource_without_cascade( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, + status_code=202, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = syncify(self.authorization.delete_resource("res_01ABC")) + + assert response is None + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith("/authorization/resources/res_01ABC") + + def test_delete_resource_with_cascade(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, + status_code=202, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = syncify( + self.authorization.delete_resource("res_01ABC", cascade_delete=True) + ) + + assert response is None + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith("/authorization/resources/res_01ABC") + assert request_kwargs["json"] == {"cascade_delete": True} diff --git a/tests/test_authorization_resource_external_id.py b/tests/test_authorization_resource_external_id.py new file mode 100644 index 00000000..10eb6b82 --- /dev/null +++ b/tests/test_authorization_resource_external_id.py @@ -0,0 +1,272 @@ +from typing import Union + +import pytest +from tests.utils.fixtures.mock_resource import MockResource +from tests.utils.list_resource import list_response_of +from tests.utils.syncify import syncify +from tests.types.test_auto_pagination_function import TestAutoPaginationFunction +from workos.authorization import AsyncAuthorization, Authorization + + +MOCK_ORG_ID = "org_01EHT88Z8J8795GZNQ4ZP1J81T" +MOCK_RESOURCE_TYPE = "document" +MOCK_EXTERNAL_ID = "ext_123" + + +@pytest.mark.sync_and_async(Authorization, AsyncAuthorization) +class TestAuthorizationResourceExternalId: + @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_resource(self): + return MockResource( + id="res_01ABC", + external_id=MOCK_EXTERNAL_ID, + resource_type_slug=MOCK_RESOURCE_TYPE, + organization_id=MOCK_ORG_ID, + ).dict() + + @pytest.fixture + def mock_resources_list(self, mock_resource): + return list_response_of(data=[mock_resource]) + + @pytest.fixture + def mock_resources_empty_list(self): + return list_response_of(data=[]) + + @pytest.fixture + def mock_resources_multiple(self): + resources = [ + MockResource(id=f"res_{i:05d}", external_id=f"ext_{i}").dict() + for i in range(15) + ] + return resources + + # --- get_resource_by_external_id --- + + def test_get_resource_by_external_id( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + resource = syncify( + self.authorization.get_resource_by_external_id( + MOCK_ORG_ID, MOCK_RESOURCE_TYPE, MOCK_EXTERNAL_ID + ) + ) + + assert resource.id == "res_01ABC" + assert resource.external_id == MOCK_EXTERNAL_ID + assert resource.object == "authorization_resource" + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith( + f"/authorization/organizations/{MOCK_ORG_ID}/resources/{MOCK_RESOURCE_TYPE}/{MOCK_EXTERNAL_ID}" + ) + + def test_get_resource_by_external_id_url_construction( + self, mock_resource, capture_and_mock_http_client_request + ): + org_id = "org_different" + res_type = "folder" + ext_id = "my-folder-123" + + mock_res = MockResource( + id="res_02XYZ", + external_id=ext_id, + resource_type_slug=res_type, + organization_id=org_id, + ).dict() + + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_res, 200 + ) + + resource = syncify( + self.authorization.get_resource_by_external_id(org_id, res_type, ext_id) + ) + + assert resource.id == "res_02XYZ" + assert request_kwargs["url"].endswith( + f"/authorization/organizations/{org_id}/resources/{res_type}/{ext_id}" + ) + + # --- update_resource_by_external_id --- + + def test_update_resource_by_external_id_with_name( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + resource = syncify( + self.authorization.update_resource_by_external_id( + MOCK_ORG_ID, + MOCK_RESOURCE_TYPE, + MOCK_EXTERNAL_ID, + name="Updated Name", + description="Updated description", + ) + ) + + assert resource.id == "res_01ABC" + assert request_kwargs["method"] == "patch" + assert request_kwargs["url"].endswith( + f"/authorization/organizations/{MOCK_ORG_ID}/resources/{MOCK_RESOURCE_TYPE}/{MOCK_EXTERNAL_ID}" + ) + assert request_kwargs["json"] == { + "name": "Updated Name", + "description": "Updated description", + } + + def test_update_resource_by_external_id_empty( + self, mock_resource, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resource, 200 + ) + + syncify( + self.authorization.update_resource_by_external_id( + MOCK_ORG_ID, MOCK_RESOURCE_TYPE, MOCK_EXTERNAL_ID + ) + ) + + assert request_kwargs["method"] == "patch" + assert request_kwargs["json"] == {} + + # --- delete_resource_by_external_id --- + + def test_delete_resource_by_external_id_without_cascade( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, + status_code=202, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = syncify( + self.authorization.delete_resource_by_external_id( + MOCK_ORG_ID, MOCK_RESOURCE_TYPE, MOCK_EXTERNAL_ID + ) + ) + + assert response is None + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith( + f"/authorization/organizations/{MOCK_ORG_ID}/resources/{MOCK_RESOURCE_TYPE}/{MOCK_EXTERNAL_ID}" + ) + + def test_delete_resource_by_external_id_with_cascade( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, + status_code=202, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = syncify( + self.authorization.delete_resource_by_external_id( + MOCK_ORG_ID, + MOCK_RESOURCE_TYPE, + MOCK_EXTERNAL_ID, + cascade_delete=True, + ) + ) + + assert response is None + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith( + f"/authorization/organizations/{MOCK_ORG_ID}/resources/{MOCK_RESOURCE_TYPE}/{MOCK_EXTERNAL_ID}" + ) + assert request_kwargs["params"] == {"cascade_delete": True} + + # --- list_resources --- + + def test_list_resources_with_results( + self, mock_resources_list, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resources_list, 200 + ) + + resources_response = syncify( + self.authorization.list_resources(organization_id=MOCK_ORG_ID) + ) + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/authorization/resources") + assert request_kwargs["params"]["organization_id"] == MOCK_ORG_ID + assert len(resources_response.data) == 1 + assert resources_response.data[0].id == "res_01ABC" + + def test_list_resources_empty_results( + self, mock_resources_empty_list, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resources_empty_list, 200 + ) + + resources_response = syncify( + self.authorization.list_resources(organization_id=MOCK_ORG_ID) + ) + + assert request_kwargs["method"] == "get" + assert len(resources_response.data) == 0 + + def test_list_resources_with_resource_type_slug_filter( + self, mock_resources_list, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resources_list, 200 + ) + + syncify( + self.authorization.list_resources( + organization_id=MOCK_ORG_ID, resource_type_slug="document" + ) + ) + + assert request_kwargs["method"] == "get" + assert request_kwargs["params"]["resource_type_slug"] == "document" + + def test_list_resources_with_pagination_params( + self, mock_resources_list, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_resources_list, 200 + ) + + syncify( + self.authorization.list_resources( + organization_id=MOCK_ORG_ID, + limit=5, + after="res_cursor_abc", + before="res_cursor_xyz", + order="asc", + ) + ) + + assert request_kwargs["params"]["limit"] == 5 + assert request_kwargs["params"]["after"] == "res_cursor_abc" + assert request_kwargs["params"]["before"] == "res_cursor_xyz" + assert request_kwargs["params"]["order"] == "asc" + + def test_list_resources_auto_pagination( + self, + mock_resources_multiple, + test_auto_pagination: TestAutoPaginationFunction, + ): + test_auto_pagination( + http_client=self.http_client, + list_function=self.authorization.list_resources, + expected_all_page_data=mock_resources_multiple, + list_function_params={"organization_id": MOCK_ORG_ID}, + ) diff --git a/tests/test_authorization_types.py b/tests/test_authorization_types.py new file mode 100644 index 00000000..a3480bb5 --- /dev/null +++ b/tests/test_authorization_types.py @@ -0,0 +1,144 @@ +"""Tests for new authorization types: Resource, RoleAssignment, AccessEvaluation, +AuthorizationOrganizationMembership.""" + +from workos.types.authorization import ( + AccessEvaluation, + AuthorizationOrganizationMembership, + Resource, + RoleAssignment, + RoleAssignmentResource, + RoleAssignmentRole, +) + + +class TestAccessEvaluation: + def test_authorized_true(self): + result = AccessEvaluation(authorized=True) + assert result.authorized is True + + def test_authorized_false(self): + result = AccessEvaluation(authorized=False) + assert result.authorized is False + + def test_from_dict(self): + result = AccessEvaluation.model_validate({"authorized": True}) + assert result.authorized is True + + +class TestResource: + def test_resource_deserialization(self): + data = { + "object": "authorization_resource", + "id": "res_01ABC", + "external_id": "ext_123", + "name": "Test Document", + "resource_type_slug": "document", + "organization_id": "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + resource = Resource.model_validate(data) + + assert resource.object == "authorization_resource" + assert resource.id == "res_01ABC" + assert resource.external_id == "ext_123" + assert resource.name == "Test Document" + assert resource.resource_type_slug == "document" + assert resource.organization_id == "org_01EHT88Z8J8795GZNQ4ZP1J81T" + assert resource.description is None + assert resource.parent_resource_id is None + + def test_resource_with_optional_fields(self): + data = { + "object": "authorization_resource", + "id": "res_01ABC", + "external_id": "ext_123", + "name": "Test Document", + "description": "A test document resource", + "resource_type_slug": "document", + "organization_id": "org_01EHT88Z8J8795GZNQ4ZP1J81T", + "parent_resource_id": "res_01PARENT", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + resource = Resource.model_validate(data) + + assert resource.description == "A test document resource" + assert resource.parent_resource_id == "res_01PARENT" + + +class TestRoleAssignment: + def test_role_assignment_deserialization(self): + data = { + "object": "role_assignment", + "id": "ra_01ABC", + "role": {"slug": "admin"}, + "resource": { + "id": "res_01ABC", + "external_id": "ext_123", + "resource_type_slug": "document", + }, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + assignment = RoleAssignment.model_validate(data) + + assert assignment.object == "role_assignment" + assert assignment.id == "ra_01ABC" + assert assignment.role.slug == "admin" + assert assignment.resource.id == "res_01ABC" + assert assignment.resource.external_id == "ext_123" + assert assignment.resource.resource_type_slug == "document" + + def test_role_assignment_role(self): + role = RoleAssignmentRole(slug="editor") + assert role.slug == "editor" + + def test_role_assignment_resource(self): + resource = RoleAssignmentResource( + id="res_01ABC", + external_id="ext_123", + resource_type_slug="document", + ) + assert resource.id == "res_01ABC" + assert resource.external_id == "ext_123" + assert resource.resource_type_slug == "document" + + +class TestAuthorizationOrganizationMembership: + def test_membership_deserialization(self): + data = { + "object": "organization_membership", + "id": "om_01ABC", + "user_id": "user_01ABC", + "organization_id": "org_01ABC", + "organization_name": "Test Org", + "status": "active", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + membership = AuthorizationOrganizationMembership.model_validate(data) + + assert membership.object == "organization_membership" + assert membership.id == "om_01ABC" + assert membership.user_id == "user_01ABC" + assert membership.organization_id == "org_01ABC" + assert membership.organization_name == "Test Org" + assert membership.status == "active" + assert membership.custom_attributes is None + + def test_membership_with_custom_attributes(self): + data = { + "object": "organization_membership", + "id": "om_01ABC", + "user_id": "user_01ABC", + "organization_id": "org_01ABC", + "organization_name": "Test Org", + "status": "active", + "custom_attributes": {"department": "Engineering"}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + } + membership = AuthorizationOrganizationMembership.model_validate(data) + + assert membership.custom_attributes == {"department": "Engineering"} diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index edbba0b4..c75ae504 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -372,3 +372,36 @@ def test_request_removes_none_json_values( json={"organization_id": None, "test": "value"}, ) assert request_kwargs["json"] == {"test": "value"} + + def test_delete_with_body_sends_json(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) + + self.http_client.delete_with_body( + path="/test", + json={"resource_id": "res_01ABC"}, + ) + + assert request_kwargs["method"] == "delete" + assert request_kwargs["json"] == {"resource_id": "res_01ABC"} + + def test_delete_with_body_sends_params(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) + + self.http_client.delete_with_body( + path="/test", + json={"resource_id": "res_01ABC"}, + params={"org_id": "org_01ABC"}, + ) + + assert request_kwargs["params"] == {"org_id": "org_01ABC"} + assert request_kwargs["json"] == {"resource_id": "res_01ABC"} + + def test_delete_without_body_raises_value_error(self): + with pytest.raises( + ValueError, match="Cannot send a body with a delete request" + ): + self.http_client.request( + path="/test", + method="delete", + json={"should": "fail"}, + ) diff --git a/tests/utils/fixtures/mock_resource.py b/tests/utils/fixtures/mock_resource.py new file mode 100644 index 00000000..825bf5fb --- /dev/null +++ b/tests/utils/fixtures/mock_resource.py @@ -0,0 +1,25 @@ +import datetime + +from workos.types.authorization.resource import Resource + + +class MockResource(Resource): + def __init__( + self, + id: str = "res_01ABC", + external_id: str = "ext_123", + name: str = "Test Resource", + resource_type_slug: str = "document", + organization_id: str = "org_01EHT88Z8J8795GZNQ4ZP1J81T", + ): + now = datetime.datetime.now().isoformat() + super().__init__( + object="authorization_resource", + id=id, + external_id=external_id, + name=name, + resource_type_slug=resource_type_slug, + organization_id=organization_id, + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_role_assignment.py b/tests/utils/fixtures/mock_role_assignment.py new file mode 100644 index 00000000..23b2dcde --- /dev/null +++ b/tests/utils/fixtures/mock_role_assignment.py @@ -0,0 +1,31 @@ +import datetime + +from workos.types.authorization.role_assignment import ( + RoleAssignment, + RoleAssignmentResource, + RoleAssignmentRole, +) + + +class MockRoleAssignment(RoleAssignment): + def __init__( + self, + id: str = "ra_01ABC", + role_slug: str = "admin", + resource_id: str = "res_01ABC", + resource_external_id: str = "ext_123", + resource_type_slug: str = "document", + ): + now = datetime.datetime.now().isoformat() + super().__init__( + object="role_assignment", + id=id, + role=RoleAssignmentRole(slug=role_slug), + resource=RoleAssignmentResource( + id=resource_id, + external_id=resource_external_id, + resource_type_slug=resource_type_slug, + ), + created_at=now, + updated_at=now, + )