From b344d14683997e64c1f240a5b6700a95bc5e6d82 Mon Sep 17 00:00:00 2001 From: swaroopakkineni Date: Fri, 20 Feb 2026 11:03:41 -1000 Subject: [PATCH 1/5] FGA pt 2 --- src/workos/authorization.py | 85 ++++++++++++++++++ tests/test_authorization_check.py | 143 ++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 tests/test_authorization_check.py diff --git a/src/workos/authorization.py b/src/workos/authorization.py index 6e12f035..cba4fba1 100644 --- a/src/workos/authorization.py +++ b/src/workos/authorization.py @@ -2,6 +2,7 @@ from pydantic import TypeAdapter +from workos.types.authorization.access_evaluation import AccessEvaluation from workos.types.authorization.environment_role import ( EnvironmentRole, EnvironmentRoleList, @@ -161,6 +162,18 @@ def add_environment_role_permission( permission_slug: str, ) -> SyncOrAsync[EnvironmentRole]: ... + # Access Evaluation + + def check( + self, + organization_membership_id: str, + *, + permission_slug: str, + resource_id: Optional[str] = None, + resource_external_id: Optional[str] = None, + resource_type_slug: Optional[str] = None, + ) -> SyncOrAsync[AccessEvaluation]: ... + class Authorization(AuthorizationModule): _http_client: SyncHTTPClient @@ -437,6 +450,42 @@ def add_environment_role_permission( return EnvironmentRole.model_validate(response) + # Access Evaluation + + def check( + self, + organization_membership_id: str, + *, + permission_slug: str, + resource_id: Optional[str] = None, + resource_external_id: Optional[str] = None, + resource_type_slug: Optional[str] = None, + ) -> AccessEvaluation: + if resource_id is not None and resource_external_id is not None: + raise ValueError( + "resource_id and resource_external_id are mutually exclusive" + ) + if resource_external_id is not None and resource_type_slug is None: + raise ValueError( + "resource_type_slug is required when resource_external_id is provided" + ) + + json: Dict[str, Any] = {"permission_slug": permission_slug} + if resource_id is not None: + json["resource_id"] = resource_id + if resource_external_id is not None: + json["resource_external_id"] = resource_external_id + if resource_type_slug is not None: + json["resource_type_slug"] = resource_type_slug + + 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 +761,39 @@ async def add_environment_role_permission( ) return EnvironmentRole.model_validate(response) + + # Access Evaluation + + async def check( + self, + organization_membership_id: str, + *, + permission_slug: str, + resource_id: Optional[str] = None, + resource_external_id: Optional[str] = None, + resource_type_slug: Optional[str] = None, + ) -> AccessEvaluation: + if resource_id is not None and resource_external_id is not None: + raise ValueError( + "resource_id and resource_external_id are mutually exclusive" + ) + if resource_external_id is not None and resource_type_slug is None: + raise ValueError( + "resource_type_slug is required when resource_external_id is provided" + ) + + json: Dict[str, Any] = {"permission_slug": permission_slug} + if resource_id is not None: + json["resource_id"] = resource_id + if resource_external_id is not None: + json["resource_external_id"] = resource_external_id + if resource_type_slug is not None: + json["resource_type_slug"] = resource_type_slug + + 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/tests/test_authorization_check.py b/tests/test_authorization_check.py new file mode 100644 index 00000000..e5b27755 --- /dev/null +++ b/tests/test_authorization_check.py @@ -0,0 +1,143 @@ +from typing import Union + +import pytest +from tests.utils.syncify import syncify +from workos.authorization import AsyncAuthorization, Authorization + + +@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_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_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_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_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", + ) + ) + + assert request_kwargs["url"].endswith( + "/authorization/organization_memberships/om_01MEMBERSHIP/check" + ) + assert request_kwargs["json"] == {"permission_slug": "admin:access"} + + def test_check_raises_when_both_resource_identifiers_provided(self): + with pytest.raises(ValueError, match="mutually exclusive"): + syncify( + self.authorization.check( + "om_01ABC", + permission_slug="documents:read", + resource_id="res_01ABC", + resource_external_id="ext_doc_123", + resource_type_slug="document", + ) + ) + + def test_check_raises_when_external_id_without_type_slug(self): + with pytest.raises(ValueError, match="resource_type_slug is required"): + syncify( + self.authorization.check( + "om_01ABC", + permission_slug="documents:read", + resource_external_id="ext_doc_123", + ) + ) From 0fb70d2af24eb37ac13e24d8bbfd349f71f1abd0 Mon Sep 17 00:00:00 2001 From: swaroopakkineni Date: Mon, 23 Feb 2026 05:25:14 -1000 Subject: [PATCH 2/5] cleanup --- src/workos/authorization.py | 45 +++---------------- src/workos/types/authorization/__init__.py | 5 +++ .../authorization/resource_identifier.py | 15 +++++++ tests/test_authorization_check.py | 40 +++++------------ 4 files changed, 38 insertions(+), 67 deletions(-) create mode 100644 src/workos/types/authorization/resource_identifier.py diff --git a/src/workos/authorization.py b/src/workos/authorization.py index cba4fba1..b2c9d043 100644 --- a/src/workos/authorization.py +++ b/src/workos/authorization.py @@ -9,6 +9,7 @@ ) 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.role import Role, RoleList from workos.types.list_resource import ( ListArgs, @@ -169,9 +170,7 @@ def check( organization_membership_id: str, *, permission_slug: str, - resource_id: Optional[str] = None, - resource_external_id: Optional[str] = None, - resource_type_slug: Optional[str] = None, + resource: ResourceIdentifier, ) -> SyncOrAsync[AccessEvaluation]: ... @@ -457,26 +456,10 @@ def check( organization_membership_id: str, *, permission_slug: str, - resource_id: Optional[str] = None, - resource_external_id: Optional[str] = None, - resource_type_slug: Optional[str] = None, + resource: ResourceIdentifier, ) -> AccessEvaluation: - if resource_id is not None and resource_external_id is not None: - raise ValueError( - "resource_id and resource_external_id are mutually exclusive" - ) - if resource_external_id is not None and resource_type_slug is None: - raise ValueError( - "resource_type_slug is required when resource_external_id is provided" - ) - json: Dict[str, Any] = {"permission_slug": permission_slug} - if resource_id is not None: - json["resource_id"] = resource_id - if resource_external_id is not None: - json["resource_external_id"] = resource_external_id - if resource_type_slug is not None: - json["resource_type_slug"] = resource_type_slug + json.update(resource) response = self._http_client.request( f"authorization/organization_memberships/{organization_membership_id}/check", @@ -769,26 +752,10 @@ async def check( organization_membership_id: str, *, permission_slug: str, - resource_id: Optional[str] = None, - resource_external_id: Optional[str] = None, - resource_type_slug: Optional[str] = None, + resource: ResourceIdentifier, ) -> AccessEvaluation: - if resource_id is not None and resource_external_id is not None: - raise ValueError( - "resource_id and resource_external_id are mutually exclusive" - ) - if resource_external_id is not None and resource_type_slug is None: - raise ValueError( - "resource_type_slug is required when resource_external_id is provided" - ) - json: Dict[str, Any] = {"permission_slug": permission_slug} - if resource_id is not None: - json["resource_id"] = resource_id - if resource_external_id is not None: - json["resource_external_id"] = resource_external_id - if resource_type_slug is not None: - json["resource_type_slug"] = resource_type_slug + json.update(resource) response = await self._http_client.request( f"authorization/organization_memberships/{organization_membership_id}/check", diff --git a/src/workos/types/authorization/__init__.py b/src/workos/types/authorization/__init__.py index 9eb705a0..93946662 100644 --- a/src/workos/types/authorization/__init__.py +++ b/src/workos/types/authorization/__init__.py @@ -13,6 +13,11 @@ ) 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, 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/tests/test_authorization_check.py b/tests/test_authorization_check.py index e5b27755..09fad9ba 100644 --- a/tests/test_authorization_check.py +++ b/tests/test_authorization_check.py @@ -3,6 +3,10 @@ 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) @@ -31,7 +35,7 @@ def test_check_authorized( self.authorization.check( "om_01ABC", permission_slug="documents:read", - resource_id="res_01ABC", + resource=ResourceIdentifierById(resource_id="res_01ABC"), ) ) @@ -52,7 +56,7 @@ def test_check_unauthorized( self.authorization.check( "om_01ABC", permission_slug="documents:write", - resource_id="res_01ABC", + resource=ResourceIdentifierById(resource_id="res_01ABC"), ) ) @@ -70,7 +74,7 @@ def test_check_with_resource_id( self.authorization.check( "om_01ABC", permission_slug="documents:read", - resource_id="res_01XYZ", + resource=ResourceIdentifierById(resource_id="res_01XYZ"), ) ) @@ -90,8 +94,10 @@ def test_check_with_resource_external_id( self.authorization.check( "om_01ABC", permission_slug="documents:read", - resource_external_id="ext_doc_123", - resource_type_slug="document", + resource=ResourceIdentifierByExternalId( + resource_external_id="ext_doc_123", + resource_type_slug="document", + ), ) ) @@ -112,32 +118,10 @@ def test_check_url_construction( 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" ) - assert request_kwargs["json"] == {"permission_slug": "admin:access"} - - def test_check_raises_when_both_resource_identifiers_provided(self): - with pytest.raises(ValueError, match="mutually exclusive"): - syncify( - self.authorization.check( - "om_01ABC", - permission_slug="documents:read", - resource_id="res_01ABC", - resource_external_id="ext_doc_123", - resource_type_slug="document", - ) - ) - - def test_check_raises_when_external_id_without_type_slug(self): - with pytest.raises(ValueError, match="resource_type_slug is required"): - syncify( - self.authorization.check( - "om_01ABC", - permission_slug="documents:read", - resource_external_id="ext_doc_123", - ) - ) From 4ccc1d9caf4bc5f8cf0f8f7c78d1da1a705c9b07 Mon Sep 17 00:00:00 2001 From: swaroopakkineni Date: Mon, 23 Feb 2026 05:32:09 -1000 Subject: [PATCH 3/5] comment --- src/workos/authorization.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/workos/authorization.py b/src/workos/authorization.py index b2c9d043..45ae6e2f 100644 --- a/src/workos/authorization.py +++ b/src/workos/authorization.py @@ -449,8 +449,6 @@ def add_environment_role_permission( return EnvironmentRole.model_validate(response) - # Access Evaluation - def check( self, organization_membership_id: str, From f26a6bfca1e871348853929e0ddf00179375f64a Mon Sep 17 00:00:00 2001 From: swaroopakkineni Date: Tue, 24 Feb 2026 11:14:43 -1000 Subject: [PATCH 4/5] merge conflict --- src/workos/authorization.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/workos/authorization.py b/src/workos/authorization.py index d0572a69..19744c8e 100644 --- a/src/workos/authorization.py +++ b/src/workos/authorization.py @@ -32,7 +32,6 @@ REQUEST_METHOD_PUT, ) - class _Unset(Enum): TOKEN = 0 @@ -78,7 +77,6 @@ class PermissionListFilters(ListArgs, total=False): Permission, PermissionListFilters, ListMetadata ] - class AuthorizationModule(Protocol): """Offers methods through the WorkOS Authorization service.""" @@ -750,7 +748,6 @@ def check( return AccessEvaluation.model_validate(response) - class AsyncAuthorization(AuthorizationModule): _http_client: AsyncHTTPClient From d8574abec6e594bc6afde0c1e74b114a07389d26 Mon Sep 17 00:00:00 2001 From: swaroopakkineni Date: Tue, 24 Feb 2026 11:15:44 -1000 Subject: [PATCH 5/5] merge conflict --- src/workos/authorization.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/workos/authorization.py b/src/workos/authorization.py index 19744c8e..d0572a69 100644 --- a/src/workos/authorization.py +++ b/src/workos/authorization.py @@ -32,6 +32,7 @@ REQUEST_METHOD_PUT, ) + class _Unset(Enum): TOKEN = 0 @@ -77,6 +78,7 @@ class PermissionListFilters(ListArgs, total=False): Permission, PermissionListFilters, ListMetadata ] + class AuthorizationModule(Protocol): """Offers methods through the WorkOS Authorization service.""" @@ -748,6 +750,7 @@ def check( return AccessEvaluation.model_validate(response) + class AsyncAuthorization(AuthorizationModule): _http_client: AsyncHTTPClient