From 5ef3d68738e784cf4475cc70f75f8d73da66e213 Mon Sep 17 00:00:00 2001 From: icode247 Date: Sat, 8 Feb 2025 06:50:32 +0100 Subject: [PATCH 1/7] Add PermissionsApi class with methods for retrieving user permissions --- permit/api/permissions.py | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 permit/api/permissions.py diff --git a/permit/api/permissions.py b/permit/api/permissions.py new file mode 100644 index 0000000..5399c92 --- /dev/null +++ b/permit/api/permissions.py @@ -0,0 +1,121 @@ +from typing import List, Dict, Optional +from ..utils.pydantic_version import PYDANTIC_VERSION + +if PYDANTIC_VERSION < (2, 0): + from pydantic import BaseModel, validate_arguments +else: + from pydantic.v1 import BaseModel, validate_arguments + +from .base import ( + BasePermitApi, + SimpleHttpClient, +) +from .context import ApiContextLevel, ApiKeyAccessLevel + +class RolePermission(BaseModel): + class Config: + arbitrary_types_allowed = True + + name: str + description: Optional[str] = None + permissions: List[str] + attributes: Optional[Dict] = None + extends: List[str] = [] + granted_to: Optional[str] = None + key: str + id: str + organization_id: str + project_id: str + environment_id: str + created_at: str + updated_at: str + +class PermissionsApi(BasePermitApi): + @property + def __users(self) -> SimpleHttpClient: + if self.config.proxy_facts_via_pdp: + return self._build_http_client("/facts/users", use_pdp=True) + else: + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/users" + ) + + @property + def __roles(self) -> SimpleHttpClient: + return self._build_http_client( + f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/roles" + ) + + @validate_arguments # type: ignore[operator] + async def get_user_permissions( + self, + user: str, + resource_type: Optional[str] = None + ) -> List[RolePermission]: + """ + Get all permissions for a user. + + Args: + user: The user key/id + resource_type: Optional resource type to filter by + + Returns: + List of role permissions for the user. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint context. + """ + await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) + await self._ensure_context(ApiContextLevel.ENVIRONMENT) + + user_data = await self.__users.get(f"/{user}", model=Dict) + permissions = [] + + for role in user_data.get("roles", []): + role_data = await self.__roles.get(f"/{role['role']}", model=RolePermission) + permissions.append(role_data) + + return permissions + + @validate_arguments # type: ignore[operator] + async def filter_objects( + self, + user: str, + objects: List[Dict], + action: str, + resource_type: str, + id_field: str = "id", + filter_ids: Optional[List[str]] = None + ) -> List[Dict]: + """ + Filter objects based on user permissions or provided IDs. + + Args: + user: The user key/id + objects: List of objects to filter + action: The action to check (e.g., "read") + resource_type: Type of resource + id_field: Field containing object ID (default: "id") + filter_ids: Optional list of IDs to filter with + + Returns: + Filtered list of objects user has permission to access. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint context. + """ + await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) + await self._ensure_context(ApiContextLevel.ENVIRONMENT) + + if filter_ids is not None: + return [obj for obj in objects if obj.get(id_field) in filter_ids] + + role_permissions = await self.get_user_permissions(user) + permission_to_check = f"{resource_type}:{action}" + + for role in role_permissions: + if permission_to_check in role.permissions: + return objects + return [] \ No newline at end of file From 17523c247d7590e4265d57711775b7d3a811dca4 Mon Sep 17 00:00:00 2001 From: icode247 Date: Sat, 8 Feb 2025 06:51:33 +0100 Subject: [PATCH 2/7] Add API for managing user permissions. --- permit/api/api_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/permit/api/api_client.py b/permit/api/api_client.py index dbc68e8..ee684dd 100644 --- a/permit/api/api_client.py +++ b/permit/api/api_client.py @@ -16,6 +16,7 @@ from .roles import RolesApi from .tenants import TenantsApi from .users import UsersApi +from .permissions import PermissionsApi class PermitApiClient(DeprecatedApi): @@ -44,6 +45,8 @@ def __init__(self, config: PermitConfig): self._roles = RolesApi(config) self._tenants = TenantsApi(config) self._users = UsersApi(config) + self._permissions = PermissionsApi(config) + @property def condition_set_rules(self) -> ConditionSetRulesApi: @@ -172,3 +175,8 @@ def users(self) -> UsersApi: See: https://api.permit.io/v2/redoc#tag/Users """ return self._users + + @property + def permissions(self) -> PermissionsApi: + """API for managing user permissions.""" + return self._permissions \ No newline at end of file From fb0ee165f187122bdbfadc94320eb53455b5fcaf Mon Sep 17 00:00:00 2001 From: icode247 Date: Sat, 8 Feb 2025 06:52:12 +0100 Subject: [PATCH 3/7] Add test suite for new permission methods --- tests/endpoints/test_permissions.py | 123 ++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/endpoints/test_permissions.py diff --git a/tests/endpoints/test_permissions.py b/tests/endpoints/test_permissions.py new file mode 100644 index 0000000..4e0f1e2 --- /dev/null +++ b/tests/endpoints/test_permissions.py @@ -0,0 +1,123 @@ +from contextlib import contextmanager +import uuid +import pytest +from loguru import logger +from permit import Permit, PermitApiError, RoleCreate, UserCreate, RoleAssignmentCreate + +# Test constants +TEST_USER = UserCreate( + key=str(uuid.uuid4()), + email="test@permit.io", + first_name="Test", + last_name="User", + attributes={"department": "Engineering"} +) + +TEST_ROLE_ADMIN = RoleCreate( + key=f"admin-{uuid.uuid4()}", + name="Admin", + permissions=["Document:read", "Document:write", "Blog:read", "Blog:write"] +) + +TEST_ROLE_VIEWER = RoleCreate( + key=f"viewer-{uuid.uuid4()}", + name="Viewer", + permissions=["Document:read", "Blog:read"] +) + +@contextmanager +def suppress_409(): + try: + yield + except PermitApiError as e: + if e.status_code != 409: + raise e + +@pytest.mark.xfail() +async def setup_test_data(permit: Permit): + """Helper to setup test user and roles""" + logger.info("Setting up test data") + + with suppress_409(): + await permit.api.users.create(TEST_USER) + with suppress_409(): + await permit.api.roles.create(TEST_ROLE_ADMIN) + await permit.api.roles.create(TEST_ROLE_VIEWER) + + # Assign roles + await permit.api.role_assignments.bulk_assign([ + RoleAssignmentCreate( + user=TEST_USER.key, + role=TEST_ROLE_ADMIN.key, + tenant="default" + ), + RoleAssignmentCreate( + user=TEST_USER.key, + role=TEST_ROLE_VIEWER.key, + tenant="default" + ) + ]) + +@pytest.mark.xfail() +async def test_permissions(permit: Permit): + await setup_test_data(permit) + + logger.info("Testing get_user_permissions") + # Test permissions retrieval + role_permissions = await permit.api.permissions.get_user_permissions(TEST_USER.key) + assert role_permissions is not None + assert len(role_permissions) == 2 + + # Verify roles and permissions + roles = {role.key for role in role_permissions} + assert TEST_ROLE_ADMIN.key in roles + assert TEST_ROLE_VIEWER.key in roles + + logger.info("Testing filter_objects") + # Test object filtering with permissions + test_documents = [ + {"id": "doc1", "name": "Document 1"}, + {"id": "doc2", "name": "Document 2"} + ] + + # Test read permission (should succeed - both roles have it) + filtered_read = await permit.api.permissions.filter_objects( + user=TEST_USER.key, + objects=test_documents, + action="read", + resource_type="Document" + ) + assert len(filtered_read) == 2 + + # Test write permission (should succeed - admin role has it) + filtered_write = await permit.api.permissions.filter_objects( + user=TEST_USER.key, + objects=test_documents, + action="write", + resource_type="Document" + ) + assert len(filtered_write) == 2 + + logger.info("Testing filter_objects with IDs") + # Test filtering with explicit IDs + filtered_ids = await permit.api.permissions.filter_objects( + user=TEST_USER.key, + objects=test_documents, + action="read", + resource_type="Document", + filter_ids=["doc1"] + ) + assert len(filtered_ids) == 1 + assert filtered_ids[0]["id"] == "doc1" + + logger.info("Testing error cases") + # Test nonexistent user + with pytest.raises(PermitApiError) as e: + await permit.api.permissions.get_user_permissions("nonexistent-user") + assert e.value.status_code == 404 + + # Cleanup + try: + await permit.api.users.delete(TEST_USER.key) + except PermitApiError: + logger.info("Cleanup - user may be already deleted") \ No newline at end of file From 2f5176dde804c2cbb988d6e24621f461c5a74d65 Mon Sep 17 00:00:00 2001 From: icode247 Date: Mon, 10 Feb 2025 11:12:31 +0100 Subject: [PATCH 4/7] Add get_user_permissions and filter_objects methods to fetch user permissions and filter resources by permissions --- permit/api/api_client.py | 10 +-- permit/api/permissions.py | 121 --------------------------- permit/enforcement/enforcer.py | 97 +++++++++++++++++++++- permit/permit.py | 52 ++++++++++++ tests/endpoints/test_permissions.py | 123 ---------------------------- tests/test_abac_pdp.py | 60 ++++++++++++++ 6 files changed, 209 insertions(+), 254 deletions(-) delete mode 100644 permit/api/permissions.py delete mode 100644 tests/endpoints/test_permissions.py diff --git a/permit/api/api_client.py b/permit/api/api_client.py index ee684dd..3690f22 100644 --- a/permit/api/api_client.py +++ b/permit/api/api_client.py @@ -16,7 +16,6 @@ from .roles import RolesApi from .tenants import TenantsApi from .users import UsersApi -from .permissions import PermissionsApi class PermitApiClient(DeprecatedApi): @@ -45,8 +44,6 @@ def __init__(self, config: PermitConfig): self._roles = RolesApi(config) self._tenants = TenantsApi(config) self._users = UsersApi(config) - self._permissions = PermissionsApi(config) - @property def condition_set_rules(self) -> ConditionSetRulesApi: @@ -174,9 +171,4 @@ def users(self) -> UsersApi: API for managing users. See: https://api.permit.io/v2/redoc#tag/Users """ - return self._users - - @property - def permissions(self) -> PermissionsApi: - """API for managing user permissions.""" - return self._permissions \ No newline at end of file + return self._users \ No newline at end of file diff --git a/permit/api/permissions.py b/permit/api/permissions.py deleted file mode 100644 index 5399c92..0000000 --- a/permit/api/permissions.py +++ /dev/null @@ -1,121 +0,0 @@ -from typing import List, Dict, Optional -from ..utils.pydantic_version import PYDANTIC_VERSION - -if PYDANTIC_VERSION < (2, 0): - from pydantic import BaseModel, validate_arguments -else: - from pydantic.v1 import BaseModel, validate_arguments - -from .base import ( - BasePermitApi, - SimpleHttpClient, -) -from .context import ApiContextLevel, ApiKeyAccessLevel - -class RolePermission(BaseModel): - class Config: - arbitrary_types_allowed = True - - name: str - description: Optional[str] = None - permissions: List[str] - attributes: Optional[Dict] = None - extends: List[str] = [] - granted_to: Optional[str] = None - key: str - id: str - organization_id: str - project_id: str - environment_id: str - created_at: str - updated_at: str - -class PermissionsApi(BasePermitApi): - @property - def __users(self) -> SimpleHttpClient: - if self.config.proxy_facts_via_pdp: - return self._build_http_client("/facts/users", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/users" - ) - - @property - def __roles(self) -> SimpleHttpClient: - return self._build_http_client( - f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/roles" - ) - - @validate_arguments # type: ignore[operator] - async def get_user_permissions( - self, - user: str, - resource_type: Optional[str] = None - ) -> List[RolePermission]: - """ - Get all permissions for a user. - - Args: - user: The user key/id - resource_type: Optional resource type to filter by - - Returns: - List of role permissions for the user. - - Raises: - PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) - await self._ensure_context(ApiContextLevel.ENVIRONMENT) - - user_data = await self.__users.get(f"/{user}", model=Dict) - permissions = [] - - for role in user_data.get("roles", []): - role_data = await self.__roles.get(f"/{role['role']}", model=RolePermission) - permissions.append(role_data) - - return permissions - - @validate_arguments # type: ignore[operator] - async def filter_objects( - self, - user: str, - objects: List[Dict], - action: str, - resource_type: str, - id_field: str = "id", - filter_ids: Optional[List[str]] = None - ) -> List[Dict]: - """ - Filter objects based on user permissions or provided IDs. - - Args: - user: The user key/id - objects: List of objects to filter - action: The action to check (e.g., "read") - resource_type: Type of resource - id_field: Field containing object ID (default: "id") - filter_ids: Optional list of IDs to filter with - - Returns: - Filtered list of objects user has permission to access. - - Raises: - PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) - await self._ensure_context(ApiContextLevel.ENVIRONMENT) - - if filter_ids is not None: - return [obj for obj in objects if obj.get(id_field) in filter_ids] - - role_permissions = await self.get_user_permissions(user) - permission_to_check = f"{resource_type}:{action}" - - for role in role_permissions: - if permission_to_check in role.permissions: - return objects - return [] \ No newline at end of file diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 7850b69..5d55c2d 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -1,6 +1,6 @@ import json from pprint import pformat -from typing import List, Optional, TypedDict, Union +from typing import Dict, List, Optional, TypedDict, Union import aiohttp from aiohttp import ClientTimeout @@ -374,7 +374,96 @@ async def check( f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", error=err, ) from err + + async def get_user_permissions( + self, + user: Union[dict, str], + tenants: Optional[List[str]] = None, + resources: Optional[List[str]] = None, + resource_types: Optional[List[str]] = None, + config: dict = {} + ) -> dict: + """Get all permissions for a user from PDP.""" + input_data = { + "user": {"key": user} if isinstance(user, str) else user, + "tenants": tenants, + "resources": resources, + "resource_types": resource_types, + } + + async with aiohttp.ClientSession(headers=self._headers, **self._timeout_config) as session: + url = f"{self._base_url}/user-permissions" + try: + async with session.post( + url, + data=json.dumps(input_data), + ) as response: + if response.status != 200: + raise PermitConnectionError( + f"Permit.getUserPermissions() got an unexpected status code: {response.status}, " + f"please check your SDK init and make sure the PDP sidecar is configured correctly.\n" + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" + ) + + content = await response.json() + permissions = content.get('result', {}).get('permissions', {}) if 'result' in content else content + + logger.debug( + f"permit.get_user_permissions() response:\n" + f"input: {pformat(input_data, indent=2)}\n" + f"response data: {pformat(permissions, indent=2)}" + ) + return permissions + except aiohttp.ClientError as err: + logger.error(f"Error in permit.get_user_permissions(): {err}") + raise PermitConnectionError( + f"Permit SDK got error: {err}, \n" + f"and cannot connect to the PDP container, please check your configuration and make sure it's " + f"running at {self._base_url} and accepting requests. \n" + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", + error=err, + ) from err + + async def filter_objects( + self, + user: User, + action: Action, + context: Dict[str, str], + resources: List[Dict] + ) -> List[Dict]: + """ + Filter objects based on permissions using bulk check. + Port of Go's FilterObjects function. + """ + # Create check requests for each resource + requests = [] + for resource in resources: + permit_resource = { + "type": resource.get("type"), + "key": resource.get("key"), + "context": resource.get("context", {}), + "attributes": resource.get("attributes", {}), + "tenant": resource.get("tenant") + } + requests.append({ + "user": user, + "action": action, + "resource": permit_resource, + "context": context + }) + + # Use existing bulk_check + results = await self.bulk_check(requests) + + # Filter resources based on results + filtered_resources = [] + for i, result in enumerate(results): + if result: + filtered_resources.append(resources[i]) + + return filtered_resources + def _normalize_resource(self, resource: ResourceInput) -> ResourceInput: normalized_resource: ResourceInput = resource.copy() if normalized_resource.context is None: @@ -404,7 +493,13 @@ def _resource_from_string(resource: str) -> ResourceInput: if len(parts) < 1 or len(parts) > 2: raise ValueError(f"permit.check() got invalid resource string: {resource}") return ResourceInput(type=parts[0], key=(parts[1] if len(parts) > 1 else None)) + + @staticmethod + def _user_repr(user: dict) -> str: + if user.get('attributes') or user.get('email'): + return json.dumps(user) + return user['key'] class SyncEnforcer(Enforcer, metaclass=SyncClass): pass diff --git a/permit/permit.py b/permit/permit.py index 386cbf4..567d1e9 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -219,3 +219,55 @@ async def check( await permit.check(user, 'close', {'type': 'issue', 'tenant': 't1'}) """ return await self._enforcer.check(user, action, resource, context) + + async def get_user_permissions( + self, + user: User, + tenants: Optional[List[str]] = None, + resources: Optional[List[str]] = None, + resource_types: Optional[List[str]] = None, + config: dict = {} + ) -> dict: + """ + Get all permissions for a user. + + Args: + user: The user object or user key + tenants: Optional list of tenants to filter permissions + resources: Optional list of resources to filter + resource_types: Optional list of resource types to filter + config: Optional configuration dictionary + + Returns: + dict: User permissions per tenant + + Raises: + PermitConnectionError: If an error occurs while sending the request to the PDP + """ + return await self._enforcer.get_user_permissions( + user, tenants, resources, resource_types, config + ) + + async def filter_objects( + self, + user: User, + action: Action, + context: Context, + resources: List[Resource] + ) -> List[Resource]: + """ + Filter objects based on user permissions. + + Args: + user: The user object or user key + action: The action to check + context: Context for the check + resources: List of resources to filter + + Returns: + List[Resource]: Filtered list of resources user has permission to access + + Raises: + PermitConnectionError: If an error occurs while sending the request to the PDP + """ + return await self._enforcer.filter_objects(user, action, context, resources) \ No newline at end of file diff --git a/tests/endpoints/test_permissions.py b/tests/endpoints/test_permissions.py deleted file mode 100644 index 4e0f1e2..0000000 --- a/tests/endpoints/test_permissions.py +++ /dev/null @@ -1,123 +0,0 @@ -from contextlib import contextmanager -import uuid -import pytest -from loguru import logger -from permit import Permit, PermitApiError, RoleCreate, UserCreate, RoleAssignmentCreate - -# Test constants -TEST_USER = UserCreate( - key=str(uuid.uuid4()), - email="test@permit.io", - first_name="Test", - last_name="User", - attributes={"department": "Engineering"} -) - -TEST_ROLE_ADMIN = RoleCreate( - key=f"admin-{uuid.uuid4()}", - name="Admin", - permissions=["Document:read", "Document:write", "Blog:read", "Blog:write"] -) - -TEST_ROLE_VIEWER = RoleCreate( - key=f"viewer-{uuid.uuid4()}", - name="Viewer", - permissions=["Document:read", "Blog:read"] -) - -@contextmanager -def suppress_409(): - try: - yield - except PermitApiError as e: - if e.status_code != 409: - raise e - -@pytest.mark.xfail() -async def setup_test_data(permit: Permit): - """Helper to setup test user and roles""" - logger.info("Setting up test data") - - with suppress_409(): - await permit.api.users.create(TEST_USER) - with suppress_409(): - await permit.api.roles.create(TEST_ROLE_ADMIN) - await permit.api.roles.create(TEST_ROLE_VIEWER) - - # Assign roles - await permit.api.role_assignments.bulk_assign([ - RoleAssignmentCreate( - user=TEST_USER.key, - role=TEST_ROLE_ADMIN.key, - tenant="default" - ), - RoleAssignmentCreate( - user=TEST_USER.key, - role=TEST_ROLE_VIEWER.key, - tenant="default" - ) - ]) - -@pytest.mark.xfail() -async def test_permissions(permit: Permit): - await setup_test_data(permit) - - logger.info("Testing get_user_permissions") - # Test permissions retrieval - role_permissions = await permit.api.permissions.get_user_permissions(TEST_USER.key) - assert role_permissions is not None - assert len(role_permissions) == 2 - - # Verify roles and permissions - roles = {role.key for role in role_permissions} - assert TEST_ROLE_ADMIN.key in roles - assert TEST_ROLE_VIEWER.key in roles - - logger.info("Testing filter_objects") - # Test object filtering with permissions - test_documents = [ - {"id": "doc1", "name": "Document 1"}, - {"id": "doc2", "name": "Document 2"} - ] - - # Test read permission (should succeed - both roles have it) - filtered_read = await permit.api.permissions.filter_objects( - user=TEST_USER.key, - objects=test_documents, - action="read", - resource_type="Document" - ) - assert len(filtered_read) == 2 - - # Test write permission (should succeed - admin role has it) - filtered_write = await permit.api.permissions.filter_objects( - user=TEST_USER.key, - objects=test_documents, - action="write", - resource_type="Document" - ) - assert len(filtered_write) == 2 - - logger.info("Testing filter_objects with IDs") - # Test filtering with explicit IDs - filtered_ids = await permit.api.permissions.filter_objects( - user=TEST_USER.key, - objects=test_documents, - action="read", - resource_type="Document", - filter_ids=["doc1"] - ) - assert len(filtered_ids) == 1 - assert filtered_ids[0]["id"] == "doc1" - - logger.info("Testing error cases") - # Test nonexistent user - with pytest.raises(PermitApiError) as e: - await permit.api.permissions.get_user_permissions("nonexistent-user") - assert e.value.status_code == 404 - - # Cleanup - try: - await permit.api.users.delete(TEST_USER.key) - except PermitApiError: - logger.info("Cleanup - user may be already deleted") \ No newline at end of file diff --git a/tests/test_abac_pdp.py b/tests/test_abac_pdp.py index c320113..24b969f 100644 --- a/tests/test_abac_pdp.py +++ b/tests/test_abac_pdp.py @@ -1,3 +1,4 @@ +from typing import Dict, List import pytest from permit import Permit, PermitConnectionError, TenantCreate, UserCreate @@ -33,3 +34,62 @@ async def test_abac_pdp_cloud_error(permit_cloud: Permit): else: pytest.fail("Should have raised an exception") + +async def test_get_user_permissions_cloud_error(permit_cloud: Permit): + user_test = UserCreate( + key="maya@permit.io", + email="maya@permit.io", + first_name="Maya", + last_name="Barak", + attributes={"age": 23}, + ) + + try: + await permit_cloud.get_user_permissions( + user={"key": user_test.key, "email": user_test.email, "attributes": user_test.attributes}, + tenants=["default"], + resources=["Blog:dddddd"], + resource_types=["Blog"] + ) + + except Exception as error: + assert isinstance(error, PermitConnectionError) + else: + pytest.fail("Should have raised an exception") + +async def test_filter_objects_cloud_error(permit_cloud: Permit): + user_test = { + "key": "maya@permit.io", + "email": "maya@permit.io", + "attributes": {"age": 23} + } + + test_resources: List[Dict] = [ + { + "type": "Blog", + "key": "doc1", + "context": {}, + "attributes": {}, + "tenant": "default" + }, + { + "type": "Document", + "key": "doc2", + "context": {}, + "attributes": {}, + "tenant": "default" + } + ] + + try: + await permit_cloud.filter_objects( + user=user_test, + action="read", + context={}, + resources=test_resources + ) + + except Exception as error: + assert isinstance(error, PermitConnectionError) + else: + pytest.fail("Should have raised an exception") \ No newline at end of file From 8e0aecca45f651b1d71d81e4fb11b32d832b88ff Mon Sep 17 00:00:00 2001 From: icode247 Date: Mon, 10 Feb 2025 15:54:38 +0100 Subject: [PATCH 5/7] (fix) fix the tests/precommit --- permit/api/api_client.py | 2 +- permit/enforcement/enforcer.py | 47 ++++++++++++------------------ permit/permit.py | 30 ++++++++----------- tests/test_abac_pdp.py | 53 ++++++++++------------------------ 4 files changed, 47 insertions(+), 85 deletions(-) diff --git a/permit/api/api_client.py b/permit/api/api_client.py index 3690f22..dbc68e8 100644 --- a/permit/api/api_client.py +++ b/permit/api/api_client.py @@ -171,4 +171,4 @@ def users(self) -> UsersApi: API for managing users. See: https://api.permit.io/v2/redoc#tag/Users """ - return self._users \ No newline at end of file + return self._users diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 5d55c2d..f81a63d 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -1,6 +1,6 @@ import json from pprint import pformat -from typing import Dict, List, Optional, TypedDict, Union +from typing import Any, Dict, List, Optional, TypedDict, Union import aiohttp from aiohttp import ClientTimeout @@ -374,16 +374,14 @@ async def check( f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", error=err, ) from err - + async def get_user_permissions( self, user: Union[dict, str], tenants: Optional[List[str]] = None, resources: Optional[List[str]] = None, resource_types: Optional[List[str]] = None, - config: dict = {} ) -> dict: - """Get all permissions for a user from PDP.""" input_data = { "user": {"key": user} if isinstance(user, str) else user, "tenants": tenants, @@ -406,8 +404,8 @@ async def get_user_permissions( ) content = await response.json() - permissions = content.get('result', {}).get('permissions', {}) if 'result' in content else content - + permissions = content.get("result", {}).get("permissions", {}) if "result" in content else content + logger.debug( f"permit.get_user_permissions() response:\n" f"input: {pformat(input_data, indent=2)}\n" @@ -424,46 +422,37 @@ async def get_user_permissions( f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", error=err, ) from err - + async def filter_objects( - self, - user: User, - action: Action, - context: Dict[str, str], - resources: List[Dict] - ) -> List[Dict]: + self, user: User, action: Action, _context: Dict[str, str], resources: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """ Filter objects based on permissions using bulk check. Port of Go's FilterObjects function. """ - # Create check requests for each resource - requests = [] + requests: List[CheckQuery] = [] for resource in resources: - permit_resource = { + permit_resource: Dict[str, Any] = { "type": resource.get("type"), "key": resource.get("key"), "context": resource.get("context", {}), "attributes": resource.get("attributes", {}), - "tenant": resource.get("tenant") + "tenant": resource.get("tenant"), } - requests.append({ + check_query: CheckQuery = { "user": user, "action": action, "resource": permit_resource, - "context": context - }) + } + requests.append(check_query) - # Use existing bulk_check results = await self.bulk_check(requests) - - # Filter resources based on results - filtered_resources = [] + filtered_resources: List[Dict[str, Any]] = [] for i, result in enumerate(results): if result: filtered_resources.append(resources[i]) - return filtered_resources - + def _normalize_resource(self, resource: ResourceInput) -> ResourceInput: normalized_resource: ResourceInput = resource.copy() if normalized_resource.context is None: @@ -493,13 +482,13 @@ def _resource_from_string(resource: str) -> ResourceInput: if len(parts) < 1 or len(parts) > 2: raise ValueError(f"permit.check() got invalid resource string: {resource}") return ResourceInput(type=parts[0], key=(parts[1] if len(parts) > 1 else None)) - @staticmethod def _user_repr(user: dict) -> str: - if user.get('attributes') or user.get('email'): + if user.get("attributes") or user.get("email"): return json.dumps(user) - return user['key'] + return user["key"] + class SyncEnforcer(Enforcer, metaclass=SyncClass): pass diff --git a/permit/permit.py b/permit/permit.py index 567d1e9..b5025c8 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -1,6 +1,6 @@ import json from contextlib import contextmanager -from typing import Generator, List, Optional +from typing import Any, Dict, Generator, List, Optional from loguru import logger from typing_extensions import Self @@ -219,14 +219,13 @@ async def check( await permit.check(user, 'close', {'type': 'issue', 'tenant': 't1'}) """ return await self._enforcer.check(user, action, resource, context) - + async def get_user_permissions( self, user: User, tenants: Optional[List[str]] = None, resources: Optional[List[str]] = None, resource_types: Optional[List[str]] = None, - config: dict = {} ) -> dict: """ Get all permissions for a user. @@ -244,30 +243,25 @@ async def get_user_permissions( Raises: PermitConnectionError: If an error occurs while sending the request to the PDP """ - return await self._enforcer.get_user_permissions( - user, tenants, resources, resource_types, config - ) + return await self._enforcer.get_user_permissions(user, tenants, resources, resource_types) async def filter_objects( - self, - user: User, - action: Action, - context: Context, - resources: List[Resource] - ) -> List[Resource]: + self, user: User, action: Action, context: Context, resources: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """ - Filter objects based on user permissions. + Get all permissions for a user. Args: user: The user object or user key - action: The action to check - context: Context for the check - resources: List of resources to filter + tenants: Optional list of tenants to filter permissions + resources: Optional list of resources to filter + resource_types: Optional list of resource types to filter + config: Optional configuration dictionary Returns: - List[Resource]: Filtered list of resources user has permission to access + dict: User permissions per tenant Raises: PermitConnectionError: If an error occurs while sending the request to the PDP """ - return await self._enforcer.filter_objects(user, action, context, resources) \ No newline at end of file + return await self._enforcer.filter_objects(user, action, context, resources) diff --git a/tests/test_abac_pdp.py b/tests/test_abac_pdp.py index 24b969f..222ec33 100644 --- a/tests/test_abac_pdp.py +++ b/tests/test_abac_pdp.py @@ -1,4 +1,6 @@ -from typing import Dict, List +from typing import Any, Dict, List + +import aiohttp import pytest from permit import Permit, PermitConnectionError, TenantCreate, UserCreate @@ -28,13 +30,12 @@ async def test_abac_pdp_cloud_error(permit_cloud: Permit): "attributes": {"private": False}, }, ) - - except Exception as error: # noqa: BLE001 + except (PermitConnectionError, aiohttp.ClientError) as error: assert isinstance(error, PermitConnectionError) - else: pytest.fail("Should have raised an exception") + async def test_get_user_permissions_cloud_error(permit_cloud: Permit): user_test = UserCreate( key="maya@permit.io", @@ -49,47 +50,25 @@ async def test_get_user_permissions_cloud_error(permit_cloud: Permit): user={"key": user_test.key, "email": user_test.email, "attributes": user_test.attributes}, tenants=["default"], resources=["Blog:dddddd"], - resource_types=["Blog"] + resource_types=["Blog"], ) - - except Exception as error: + except (PermitConnectionError, aiohttp.ClientError) as error: assert isinstance(error, PermitConnectionError) else: pytest.fail("Should have raised an exception") + async def test_filter_objects_cloud_error(permit_cloud: Permit): - user_test = { - "key": "maya@permit.io", - "email": "maya@permit.io", - "attributes": {"age": 23} - } - - test_resources: List[Dict] = [ - { - "type": "Blog", - "key": "doc1", - "context": {}, - "attributes": {}, - "tenant": "default" - }, - { - "type": "Document", - "key": "doc2", - "context": {}, - "attributes": {}, - "tenant": "default" - } + user_test = {"key": "maya@permit.io", "email": "maya@permit.io", "attributes": {"age": 23}} + + test_resources: List[Dict[str, Any]] = [ + {"type": "Blog", "key": "doc1", "context": {}, "attributes": {}, "tenant": "default"}, + {"type": "Document", "key": "doc2", "context": {}, "attributes": {}, "tenant": "default"}, ] try: - await permit_cloud.filter_objects( - user=user_test, - action="read", - context={}, - resources=test_resources - ) - - except Exception as error: + await permit_cloud.filter_objects(user=user_test, action="read", context={}, resources=test_resources) + except (PermitConnectionError, aiohttp.ClientError) as error: assert isinstance(error, PermitConnectionError) else: - pytest.fail("Should have raised an exception") \ No newline at end of file + pytest.fail("Should have raised an exception") From 3ee9f953462feabb6e41b3b8629e7c313a7fba57 Mon Sep 17 00:00:00 2001 From: icode247 Date: Mon, 10 Feb 2025 21:41:10 +0100 Subject: [PATCH 6/7] (fix): fix review changes --- permit/enforcement/enforcer.py | 10 +--------- permit/permit.py | 6 ++---- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index f81a63d..ce061c8 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -423,9 +423,7 @@ async def get_user_permissions( error=err, ) from err - async def filter_objects( - self, user: User, action: Action, _context: Dict[str, str], resources: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: + async def filter_objects(self, user: User, action: Action, resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Filter objects based on permissions using bulk check. Port of Go's FilterObjects function. @@ -483,12 +481,6 @@ def _resource_from_string(resource: str) -> ResourceInput: raise ValueError(f"permit.check() got invalid resource string: {resource}") return ResourceInput(type=parts[0], key=(parts[1] if len(parts) > 1 else None)) - @staticmethod - def _user_repr(user: dict) -> str: - if user.get("attributes") or user.get("email"): - return json.dumps(user) - return user["key"] - class SyncEnforcer(Enforcer, metaclass=SyncClass): pass diff --git a/permit/permit.py b/permit/permit.py index b5025c8..5674d00 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -245,9 +245,7 @@ async def get_user_permissions( """ return await self._enforcer.get_user_permissions(user, tenants, resources, resource_types) - async def filter_objects( - self, user: User, action: Action, context: Context, resources: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: + async def filter_objects(self, user: User, action: Action, resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Get all permissions for a user. @@ -264,4 +262,4 @@ async def filter_objects( Raises: PermitConnectionError: If an error occurs while sending the request to the PDP """ - return await self._enforcer.filter_objects(user, action, context, resources) + return await self._enforcer.filter_objects(user, action, resources) From 4253db2ea9076ae31c384e11fa7a4781fd3ede84 Mon Sep 17 00:00:00 2001 From: icode247 Date: Mon, 10 Feb 2025 21:55:17 +0100 Subject: [PATCH 7/7] (fix) add context to filter_objects --- permit/enforcement/enforcer.py | 11 +++++------ permit/permit.py | 6 ++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index ce061c8..c72f93e 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -30,6 +30,7 @@ class CheckQuery(TypedDict): user: User action: Action resource: Resource + context: Optional[Context] SETUP_PDP_DOCS_LINK = ( @@ -423,7 +424,9 @@ async def get_user_permissions( error=err, ) from err - async def filter_objects(self, user: User, action: Action, resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + async def filter_objects( + self, user: User, action: Action, context: Dict[str, str], resources: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """ Filter objects based on permissions using bulk check. Port of Go's FilterObjects function. @@ -437,11 +440,7 @@ async def filter_objects(self, user: User, action: Action, resources: List[Dict[ "attributes": resource.get("attributes", {}), "tenant": resource.get("tenant"), } - check_query: CheckQuery = { - "user": user, - "action": action, - "resource": permit_resource, - } + check_query: CheckQuery = {"user": user, "action": action, "resource": permit_resource, "context": context} requests.append(check_query) results = await self.bulk_check(requests) diff --git a/permit/permit.py b/permit/permit.py index 5674d00..b5025c8 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -245,7 +245,9 @@ async def get_user_permissions( """ return await self._enforcer.get_user_permissions(user, tenants, resources, resource_types) - async def filter_objects(self, user: User, action: Action, resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + async def filter_objects( + self, user: User, action: Action, context: Context, resources: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """ Get all permissions for a user. @@ -262,4 +264,4 @@ async def filter_objects(self, user: User, action: Action, resources: List[Dict[ Raises: PermitConnectionError: If an error occurs while sending the request to the PDP """ - return await self._enforcer.filter_objects(user, action, resources) + return await self._enforcer.filter_objects(user, action, context, resources)