From f0ed405a85175dd335f0541ea314c16fe0dda1f6 Mon Sep 17 00:00:00 2001 From: Asaf Cohen Date: Mon, 12 Aug 2024 19:35:57 +0300 Subject: [PATCH] add initial data filtering support to python sdk --- permit/enforcement/enforcer.py | 138 +++++++++++++++++++++++++++++++++ permit/permit.py | 94 ++++++++++++++++++++++ requirements.txt | 1 + setup.py | 2 +- 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 112af52..a0016af 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -5,6 +5,7 @@ import aiohttp from aiohttp import ClientTimeout from loguru import logger +from permit_datafilter.boolean_expression.schemas import ResidualPolicyResponse from pydantic import parse_obj_as from ..config import PermitConfig @@ -397,6 +398,143 @@ async def check( Read more about setting up the PDP at https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" ) + async def filter_resources( + self, + user: User, + action: Action, + resource_type: str, + context: Context = {}, + ) -> ResidualPolicyResponse: + """ + Returns a filter that can be applied to the user database that filters all the resources a user can access given a user, action and resource type. + + The filter is a residual policy compiled from OPA Rego AST and transformed to be expressed as a boolean expression + (combination of logical and comparison operators, where the operands can be variable references or literal values). + + An example for a residual policy: + { + "type": "conditional", + "condition": { + "expression": { + "operator": "eq", + "operands": [ + { + "variable": "input.resource.tenant" + }, + { + "value": "082f6978-6424-4e05-a706-1ab6f26c3768" + } + ] + } + } + } + + The user can then map this residual policy into an SQL expression using various plugins. + + Args: + user: The user object representing the user. + action: The action to be performed on the resource. + resource_type: The resource type. + context: The context object representing the context in which the action is performed. Defaults to None. + + Returns: + ResidualPolicyResponse: a residual policy that can be transformed into an SQL expression. + + Raises: + PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + + Examples: + + from sqlalchemy.orm import declarative_base, relationship + + from permit import Permit + from permit_datafilter.plugins.sqlalchemy import QueryBuilder + + # assuming we have the following SQL tables: + Base = declarative_base() + + class Tenant(Base): + __tablename__ = "tenant" + + id = Column(String, primary_key=True) + key = Column(String(255)) + + class Task(Base): + __tablename__ = "task" + + id = Column(String, primary_key=True) + created_at = Column(DateTime, default=datetime.utcnow()) + updated_at = Column(DateTime) + description = Column(String(255)) + tenant_id = Column(String, ForeignKey("tenant.id")) + tenant = relationship("Tenant", backref="tasks") + + # this is how we can filter all the task records in the database + # that are readable by the user according to the authz policy + # (i.e: that user have the `task:read` permission on them) + permit = Permit(...) + authz_filter = await permit.filter_resources("john@doe.com", "read", "task") + query = ( + QueryBuilder() + .select(Task) + .filter_by(authz_filter) + .map_references({ + # if mapping a reference to a field on a related table + "input.resource.tenant": Tenant.key, + }) + # you must specify how to perform a join against that table + .join(Tenant, Task.tenant_id == Tenant.id) + .build() + ) + """ + normalized_user: UserInput = ( + UserInput(key=user) if isinstance(user, str) else UserInput(**user) + ) + normalized_resource: ResourceInput = self._normalize_resource( + self._resource_from_string(resource_type) + ) + query_context = self._context_store.get_derived_context(context) + input = dict( + user=normalized_user.dict(exclude_unset=True), + action=action, + resource=normalized_resource.dict(exclude_unset=True), + context=query_context, + ) + async with aiohttp.ClientSession( + headers=self._headers, **self._timeout_config + ) as session: + api_url = f"{self._base_url}/filter_resources" + try: + async with session.post( + api_url, + data=json.dumps(input), + ) as response: + if response.status != 200: + raise PermitConnectionError( + f"Permit SDK got unexpected status code: {response.status}, please check your Permit SDK class init and PDP container are configured correctly. \n\ + Read more about setting up the PDP at https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" + ) + + response_data: dict = await response.json() + logger.debug( + f"permit.filter_resources() response:\ninput: {pformat(input, indent=2)}\nresponse status: {response.status}\nresponse data: {pformat(response_data, indent=2)}" + ) + return ResidualPolicyResponse(**response_data) + except aiohttp.ClientError as err: + logger.error( + "error in permit.filter_resources({}, {}, {}):\n{}".format( + normalized_user, + action, + self._resource_repr(normalized_resource), + err, + ) + ) + raise PermitConnectionError( + f"Permit SDK got error: {err}, \n \ + and cannot connect to the PDP container, please check your configuration and make sure it's running at {self._base_url} and accepting requests. \n \ + Read more about setting up the PDP at https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" + ) + def _normalize_resource(self, resource: ResourceInput) -> ResourceInput: normalized_resource: ResourceInput = resource.copy() if normalized_resource.context is None: diff --git a/permit/permit.py b/permit/permit.py index 5042af6..c784154 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -3,6 +3,7 @@ from typing import Generator, Optional from loguru import logger +from permit_datafilter.boolean_expression.schemas import ResidualPolicyResponse from pydantic import NonNegativeFloat from typing_extensions import Self @@ -224,3 +225,96 @@ async def check( await permit.check(user, 'close', {'type': 'issue', 'tenant': 't1'}) """ return await self._enforcer.check(user, action, resource, context) + + async def filter_resources( + self, + user: User, + action: Action, + resource_type: str, + context: Context = {}, + ) -> ResidualPolicyResponse: + """ + Returns a filter that can be applied to the user database that filters all the resources a user can access given a user, action and resource type. + + The filter is a residual policy compiled from OPA Rego AST and transformed to be expressed as a boolean expression + (combination of logical and comparison operators, where the operands can be variable references or literal values). + + An example for a residual policy: + { + "type": "conditional", + "condition": { + "expression": { + "operator": "eq", + "operands": [ + { + "variable": "input.resource.tenant" + }, + { + "value": "082f6978-6424-4e05-a706-1ab6f26c3768" + } + ] + } + } + } + + The user can then map this residual policy into an SQL expression using various plugins. + + Args: + user: The user object representing the user. + action: The action to be performed on the resource. + resource_type: The resource type. + context: The context object representing the context in which the action is performed. Defaults to None. + + Returns: + ResidualPolicyResponse: a residual policy that can be transformed into an SQL expression. + + Raises: + PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + + Examples: + + from sqlalchemy.orm import declarative_base, relationship + + from permit import Permit + from permit_datafilter.plugins.sqlalchemy import QueryBuilder + + # assuming we have the following SQL tables: + Base = declarative_base() + + class Tenant(Base): + __tablename__ = "tenant" + + id = Column(String, primary_key=True) + key = Column(String(255)) + + class Task(Base): + __tablename__ = "task" + + id = Column(String, primary_key=True) + created_at = Column(DateTime, default=datetime.utcnow()) + updated_at = Column(DateTime) + description = Column(String(255)) + tenant_id = Column(String, ForeignKey("tenant.id")) + tenant = relationship("Tenant", backref="tasks") + + # this is how we can filter all the task records in the database + # that are readable by the user according to the authz policy + # (i.e: that user have the `task:read` permission on them) + permit = Permit(...) + authz_filter = await permit.filter_resources("john@doe.com", "read", "task") + query = ( + QueryBuilder() + .select(Task) + .filter_by(authz_filter) + .map_references({ + # if mapping a reference to a field on a related table + "input.resource.tenant": Tenant.key, + }) + # you must specify how to perform a join against that table + .join(Tenant, Task.tenant_id == Tenant.id) + .build() + ) + """ + return await self._enforcer.filter_resources( + user, action, resource_type, context + ) diff --git a/requirements.txt b/requirements.txt index c5a8561..54289a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ httpx>=0.24.1,<1 loguru>=0.7.0,<1 pydantic[email]>=1.10.7 typing-extensions>=4.5.0,<5 +permit-datafilter>=0.0.3,<1 diff --git a/setup.py b/setup.py index 217e9e7..715c766 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ def get_readme() -> str: setup( name="permit", - version="2.6.0", + version="2.7.0", packages=find_packages(), author="Asaf Cohen", author_email="asaf@permit.io",