Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Boolean expression permissions #3408

Open
wants to merge 38 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ee8d93c
First pass looking at boolean operators on permissions
Mar 14, 2024
9a8ce6c
Boolean permissions tests
Mar 14, 2024
ea4040b
Update docs
Mar 14, 2024
ecedc67
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 14, 2024
f442f06
Formatting fix
Mar 14, 2024
7f02182
Merge remote-tracking branch 'origin/boolean-expression-permissions' …
Mar 14, 2024
8b08575
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 14, 2024
6300052
Move try catch out of loop!
Mar 14, 2024
796dc99
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 14, 2024
9c9eedc
Few fixes from AI review
Mar 14, 2024
c682525
Merge remote-tracking branch 'origin/boolean-expression-permissions' …
Mar 14, 2024
9694236
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 14, 2024
c38d868
Revert some breaking changes. Move from boolean to composite permiss…
Mar 22, 2024
d455926
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2024
af0c1d4
Absolutely hadn't finished my changes!
Mar 22, 2024
340f4a0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2024
f00a5a6
Merge branch 'strawberry-graphql:main' into boolean-expression-permis…
vethan Mar 22, 2024
7823b38
Fix to undefined Info type
Mar 22, 2024
70e695e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2024
ed67c14
Test the async versions of composite permissions, and some linting fixes
Apr 2, 2024
7064f74
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 2, 2024
62ec845
Final linting fixes
Apr 2, 2024
cf53546
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 2, 2024
496cb36
Merge branch 'strawberry-graphql:main' into boolean-expression-permis…
vethan Apr 2, 2024
6226b39
Make sure to test asyncs
Apr 4, 2024
d8c9cce
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 4, 2024
425d739
Of course I missed a single _async
Apr 4, 2024
4a2805a
Merge remote-tracking branch 'origin/boolean-expression-permissions' …
Apr 4, 2024
e1c2b68
refactor: use default has permission syntax for boolean permissions
erikwrede Apr 14, 2024
d338665
chore: adjust type annotation for kwargs
erikwrede Apr 14, 2024
49f9d81
chore: raise error cleanly
erikwrede Apr 14, 2024
08fc53c
Merge branch 'strawberry-graphql:main' into boolean-expression-permis…
vethan Apr 15, 2024
9ed6a54
fix: use correct type annotation for kwargs
erikwrede May 3, 2024
29fe77f
chore: adjust type hints
erikwrede May 3, 2024
1dd86ce
fix: support passing context when nesting permissions
erikwrede May 3, 2024
d9f016a
Merge branch 'boolean-expression-permissions-erik' into boolean-expre…
patrick91 May 16, 2024
901abf0
Merge branch 'main' into boolean-expression-permissions
patrick91 May 16, 2024
64bcbad
Merge branch 'refs/heads/boolean-expression-permissions-erik' into bo…
erikwrede May 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Release type: patch

Adds the ability to use the `&` and `|` operators on permissions to form boolean logic. For example, if you want
a field to be accessible with either the `IsAdmin` or `IsOwner` permission you
could define the field as follows:

```python
import strawberry
from strawberry.permission import PermissionExtension, BasePermission


@strawberry.type
class Query:
@strawberry.field(
extensions=[
PermissionExtension(
permissions=[(IsAdmin() | IsOwner())], fail_silently=True
)
]
)
def name(self) -> str:
return "ABC"
```
25 changes: 25 additions & 0 deletions docs/guides/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,31 @@ consider if it is possible to use alternative solutions like the `@skip` or
without permission. Check the GraphQL documentation for more information on
[directives](https://graphql.org/learn/queries/#directives).

## Boolean Operations

When using the `PermissionExtension`, it is possible to combine permissions
using the `&` and `|` operators to form boolean logic. For example, if you want
a field to be accessible with either the `IsAdmin` or `IsOwner` permission you
could define the field as follows:

```python
import strawberry
from strawberry.permission import PermissionExtension, BasePermission


@strawberry.type
class Query:
@strawberry.field(
extensions=[
PermissionExtension(
permissions=[(IsAdmin() | IsOwner())], fail_silently=True
)
]
)
def name(self) -> str:
return "ABC"
```

## Customizable Error Handling

To customize the error handling, the `on_unauthorized` method on the
Expand Down
159 changes: 147 additions & 12 deletions strawberry/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import abc
import inspect
import itertools
from functools import cached_property
from inspect import iscoroutinefunction
from typing import (
Expand All @@ -11,6 +12,7 @@
Dict,
List,
Optional,
Tuple,
Type,
Union,
)
Expand Down Expand Up @@ -61,7 +63,6 @@
Default error raising for permissions.
This can be overridden to customize the behavior.
"""

# Instantiate error class
error = self.error_class(self.message or "")

Expand Down Expand Up @@ -89,6 +90,106 @@

return self._schema_directive

@property
def is_async(self) -> bool:
return iscoroutinefunction(self.has_permission)
erikwrede marked this conversation as resolved.
Show resolved Hide resolved

def __and__(self, other: BasePermission):
return AndPermission([self, other])

def __or__(self, other: BasePermission):
return OrPermission([self, other])


class CompositePermission(BasePermission, abc.ABC):
child_permissions: List[BasePermission]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this annotation, this basically lies to type checker that this property would always be present on a class.
Your __init__ is enough to provide that type information


def __init__(self, child_permissions: List[BasePermission]):
self.child_permissions = child_permissions

def has_permission(
self, source: Any, info: Info, **kwargs: Any
) -> Union[bool, Awaitable[bool]]:
raise NotImplementedError(
"Composite Permission classes should use the "
"has_composite_permissions and has_composite_permission_async methods"
)

@abc.abstractmethod
async def has_composite_permission_async(
self, source: Any, info: Info, **kwargs: Any
) -> Tuple[bool, int]:
raise NotImplementedError(
"Permission classes should override has_permission method"
)

@abc.abstractmethod
def has_composite_permission(
self, source: Any, info: Info, **kwargs: Any
) -> Tuple[bool, int]:
raise NotImplementedError(
"Composite Permission classes should override has_permission method"
)

@property
def is_async(self) -> bool:
return any(x.is_async for x in self.child_permissions)

@property
def schema_directive_set(self) -> List[object]:
composite_list = list(
itertools.chain.from_iterable(
[
x.schema_directive_set
for x in self.child_permissions
if isinstance(x, CompositePermission)
]
)
)
return composite_list + [
x.schema_directive
for x in self.child_permissions
if not isinstance(x, CompositePermission)
]


class AndPermission(CompositePermission):
def has_composite_permission(
self, source: Any, info: Info, **kwargs: Any
) -> Tuple[bool, int]:
for index, permission in enumerate(self.child_permissions):
if not permission.has_permission(source, info, **kwargs):
return False, index
return True, 0
Copy link
Contributor

@ThirVondukr ThirVondukr Apr 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be a better idea to return failed permission and not it's index?


async def has_composite_permission_async(
self, source: Any, info: Info, **kwargs: Any
) -> Tuple[bool, int]:
for index, permission in enumerate(self.child_permissions):
if not await await_maybe(permission.has_permission(source, info, **kwargs)):
return False, index
return True, 0


class OrPermission(CompositePermission):
def has_composite_permission(
self, source: Any, info: Info, **kwargs: Any
) -> Tuple[bool, int]:
for permission in self.child_permissions:
if permission.has_permission(source, info, **kwargs):
return True, 0

return False, 0

Check warning on line 182 in strawberry/permission.py

View check run for this annotation

Codecov / codecov/patch

strawberry/permission.py#L182

Added line #L182 was not covered by tests

async def has_composite_permission_async(
self, source: Any, info: Info, **kwargs: Any
) -> Tuple[bool, int]:
for permission in self.child_permissions:
if await await_maybe(permission.has_permission(source, info, **kwargs)):
return True, 0

return False, 0

Check warning on line 191 in strawberry/permission.py

View check run for this annotation

Codecov / codecov/patch

strawberry/permission.py#L191

Added line #L191 was not covered by tests


class PermissionExtension(FieldExtension):
"""
Expand Down Expand Up @@ -122,7 +223,17 @@
"""
if self.use_directives:
field.directives.extend(
p.schema_directive for p in self.permissions if p.schema_directive
[
directive
for p in self.permissions
if isinstance(p, CompositePermission)
for directive in p.schema_directive_set
]
+ [
p.schema_directive
for p in self.permissions
if not isinstance(p, CompositePermission)
]
)
# We can only fail silently if the field is optional or a list
if self.fail_silently:
Expand Down Expand Up @@ -151,9 +262,23 @@
Checks if the permission should be accepted and
raises an exception if not
"""

for permission in self.permissions:
if not permission.has_permission(source, info, **kwargs):
return self._on_unauthorized(permission)
if isinstance(permission, CompositePermission):
has_comp_permission, failed_index = permission.has_composite_permission(
source, info, **kwargs
)

if not has_comp_permission:
return self._on_unauthorized(
permission.child_permissions[failed_index]
)
else:
has_permission = permission.has_permission(source, info, **kwargs)

if not has_permission:
return self._on_unauthorized(permission)

return next_(source, info, **kwargs)

async def resolve_async(
Expand All @@ -164,12 +289,24 @@
**kwargs: Dict[str, Any],
) -> Any:
for permission in self.permissions:
has_permission = await await_maybe(
permission.has_permission(source, info, **kwargs)
)
if isinstance(permission, CompositePermission):
(
has_permission,
failed_index,
) = await permission.has_composite_permission_async(
source, info, **kwargs
)

if not has_permission:
return self._on_unauthorized(permission)
if not has_permission:
return self._on_unauthorized(
permission.child_permissions[failed_index]
)
else:
has_permission = await await_maybe(
permission.has_permission(source, info, **kwargs)
)
if not has_permission:
return self._on_unauthorized(permission)
next = next_(source, info, **kwargs)
if inspect.isasyncgen(next):
return next
Expand All @@ -180,8 +317,6 @@
"""The Permission extension always supports async checking using await_maybe,
but only supports sync checking if there are no async permissions"""
async_permissions = [
True
for permission in self.permissions
if iscoroutinefunction(permission.has_permission)
True for permission in self.permissions if permission.is_async
]
return len(async_permissions) == 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return len(async_permissions) == 0
return all(not permission.is_async for permission in self.permissions)

Loading
Loading