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

Use polymorphic principals #10

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ An extremely simple and incomplete example:
```python
from fastapi import Depends, FastAPI
from fastapi.security import OAuth2PasswordBearer
from fastapi_permissions import configure_permissions, Allow, Deny
from fastapi_permissions import configure_permissions, Allow, Deny, UserPrincipal, RolePrincipal
from pydantic import BaseModel

app = FastAPI()
Expand All @@ -25,15 +25,15 @@ class Item(BaseModel):
def __acl__(self):
return [
(Allow, Authenticated, "view"),
(Allow, "role:admin", "edit"),
(Allow, f"user:{self.owner}", "delete"),
(Allow, RolePrincipal("admin"), "edit"),
(Allow, UserPrincipal(f"{self.owner}"), "delete"),
]

class User(BaseModel):
name: str

def principals(self):
return [f"user:{self.name}"]
return [UserPrincipal(f"{self.name}")]

def get_current_user(token: str = Depends(oauth2_scheme)):
...
Expand Down Expand Up @@ -87,15 +87,15 @@ The system depends on a couple of concepts not found in FastAPI:

- **resources**: objects that provide an *access controll list*
- **access controll lists**: a list of rules defining which *principal* has what *permission*
- **principal**: an identifier of a user or his/her associated groups/roles
- **principal**: an identifier of a user or his/her associated groups/roles. Can be any type, but usually derived from `Principal`
- **permission**: an identifier (string) for an action on an object

### resources & access controll lists

A resource provides an access controll list via it's ```__acl__``` attribute. It can either be an property of an object or a callable. Each entry in the list is a tuple containing three values:

1. an action: ```fastapi_permissions.Allow``` or ```fastapi_permissions.Deny```
2. a principal: e.g. "role:admin" or "user:bob"
2. a principal: e.g. RolePrincipal("admin") or UserPrincipal("bob")
3. a permission or a tuple thereof: e.g. "edit" or ("view", "delete")

Examples:
Expand All @@ -106,21 +106,21 @@ from fastapi_permissions import Allow, Deny, Authenticated, Everyone
class StaticAclResource:
__acl__ = [
(Allow, Everyone, "view"),
(Allow, "role:user", "share")
(Allow, RolePrincipal("user"), "share")
]

class DynamicAclResource:
def __acl__(self):
return [
(Allow, Authenticated, "view"),
(Allow, "role:user", "share"),
(Allow, f"user:{self.owner}", "edit"),
(Allow, RolePrincipal("user"), "share"),
(Allow, UserPrincipal(f"{self.owner}"), "edit"),
]

# in contrast to pyramid, resources might be access conroll list themselves
# this can save some typing:

AclResourceAsList = [(Allow, Everyone, "view"), (Deny, "role:troll", "edit")]
AclResourceAsList = [(Allow, Everyone, "view"), (Deny, RolePrincipal("troll"), "edit")]
```

You don't need to add any "deny-all-clause" at the end of the access controll list, this is automagically implied. All entries in a ACL are checked in *the order provided in the list*. This makes some complex configurations simple, but can sometimes be a pain in the lower back…
Expand All @@ -129,7 +129,7 @@ The two principals ```Everyone``` and ```Authenticated``` will be discussed in s

### users & principal identifiers

You **must provide** a function that returns the principals of the current active user. The principals is just a list of strings, identifying the user and groups/roles the user belongs to:
You **must provide** a function that returns the principals of the current active user. The principals are just a list of objects, identifying the user and groups/roles the user belongs to. They can be constructed from the provided `Principal` dataclass:

Example:

Expand Down Expand Up @@ -216,7 +216,7 @@ def get_active_principals(...):
""" returns the principals of the current logged in user"""
...

example_acl = [(Allow, "role:user", "view")]
example_acl = [(Allow, UserPrincipal("user"), "view")]

# Permission is already wrapped in Depends()
Permission = configure_permissions(get_active_principals)
Expand Down Expand Up @@ -263,26 +263,26 @@ from fastapi_permissions import (
has_permission, Allow, All, Everyone, Authenticated
)

user_principals == [Everyone, Authenticated, "role:owner", "user:bob"]
apple_acl == [(Allow, "role:owner", All)]
user_principals == [Everyone, Authenticated, RolePrincipal("owner"), UserPrincipal("bob")]
apple_acl == [(Allow, RolePrincipal("owner"), All)]

if has_permission(user_principals, "eat", apple_acl):
print "Yum!"
```

The other function provided is ```list_permissions(user_principals, resource)``` this will return a dict of all available permissions and a boolean value if the permission is granted or denied:
The other function provided is ```list_permissions(user_principals, resource)``` which returns a PermissionSet mapping all available permissions for the given resource to a boolean value representing if the permission is granted or denied. The `default` attribute of the permission set specifies whether unlisted permissions are granted or denied by default. The default is to deny, but can be overridden through the use of the `All` permission.

```python
from fastapi_permissions import list_permissions, Allow, All
from fastapi_permissions import list_permissions, Allow, All, Everyone, Authenticated, RolePrincipal, UserPrincipal

user_principals == [Everyone, Authenticated, "role:owner", "user:bob"]
apple_acl == [(Allow, "role:owner", All)]
user_principals = [Everyone, Authenticated, RolePrincipal("owner"), UserPrincipal("bob")]
apple_acl = [(Allow, RolePrincipal("owner"), All)]

print(list_permissions(user_principals, apple_acl))
{"permissions:*": True}
PermissionSet({}, default=True)
```

Please note, that ```"permissions:*"``` is the string representation of ```fastapi_permissions.All```.
Note that the `default` attribute of the `PermissionSet` indicates whether the default behaviour is to allow or deny unlisted permissions.


How it works
Expand Down
67 changes: 60 additions & 7 deletions fastapi_permissions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ async def show_item(item:Item = Permission("view", get_item)):

__version__ = "0.2.7"

from dataclasses import dataclass
import functools
import itertools
from typing import Any
Expand All @@ -62,9 +63,44 @@ async def show_item(item:Item = Permission("view", get_item)):
Allow = "Allow" # acl "allow" action
Deny = "Deny" # acl "deny" action

Everyone = "system:everyone" # user principal for everyone
Authenticated = "system:authenticated" # authenticated user principal
@dataclass(frozen=True)
class Principal:
method: Any
value: Any

Everyone = Principal("system", "everyone")
Authenticated = Principal("system", "authenticated")

@dataclass(frozen=True)
class UserPrincipal(Principal):
"""A principal with the method preset to `"id"`. Represents the singleton group of users with a given id."""
def __init__(self, value, *args, **kwargs):
super().__init__("id", value, *args, **kwargs)

@dataclass(frozen=True)
class RolePrincipal(Principal):
"""A principal with the method preset to `"role"`.

Represents the group of users with a given role (e.g. admin)."""
def __init__(self, value, *args, **kwargs):
super().__init__("role", value, *args, **kwargs)

@dataclass(frozen=True)
class ActionPrincipal(Principal):
"""A principal with the method preset to `"action"`

Represents the group of users who are permitted to perform a given action (e.g. update_blog_posts)"""
def __init__(self, value, *args, **kwargs):
super().__init__("action", value, *args, **kwargs)

@dataclass(frozen=True)
class ItemPrincipal:
"""A principal with an additional `"type"` attribute.

Represents the group of users that can perform the `"method"` action on the `"value"` instance of the `"type"` item (e.g. ("update", 27, "posts") represents the ability to update post 27)"""
method: Any
value: Any
type: Any

class _AllPermissions:
""" special container class for the all permissions constant
Expand Down Expand Up @@ -178,7 +214,8 @@ def has_permission(
acl = normalize_acl(resource)

for action, principal, permissions in acl:
if isinstance(permissions, str):
if isinstance(permissions, str) or \
permissions is not All and not hasattr(permissions, "__iter__"):
permissions = {permissions}
if requested_permission in permissions:
if principal in user_principals:
Expand All @@ -198,16 +235,32 @@ def list_permissions(user_principals: list, resource: Any):
acl = normalize_acl(resource)

acl_permissions = (permissions for _, _, permissions in acl)
as_iterables = ({p} if not is_like_list(p) else p for p in acl_permissions)
as_iterables = (p if is_like_list(p) else {p} for p in acl_permissions)
permissions = set(itertools.chain.from_iterable(as_iterables))

return {
str(p): has_permission(user_principals, p, acl) for p in permissions
}
default = False
for action, principal, permission in acl:
if permission is All and principal in user_principals:
default = action == Allow
break

return PermissionSet(
{p: has_permission(user_principals, p, acl) \
for p in permissions if p is not All},
default=default)


# utility functions

class PermissionSet(dict):
__slots__ = ("default",)
def __init__(self, values=(), /, *, default):
super().__init__(values)
self.default = default
def __missing__(self, key): return self.default
def __repr__(self):
return f"PermissionSet({dict.__repr__(self)}, default={self.default})"


def normalize_acl(resource: Any):
""" returns the access controll list for a resource
Expand Down
17 changes: 9 additions & 8 deletions fastapi_permissions/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
Everyone,
configure_permissions,
list_permissions,
UserPrincipal,
RolePrincipal
)

# >>> THIS IS NEW
Expand Down Expand Up @@ -49,7 +51,7 @@
"email": "[email protected]",
"hashed_password": pwd_context.hash("secret"),
# >>> THIS IS NEW
"principals": ["user:bob", "role:admin"],
"principals": [UserPrincipal("bob"), RolePrincipal("admin")],
# <<<
},
"alice": {
Expand All @@ -58,7 +60,7 @@
"email": "[email protected]",
"hashed_password": pwd_context.hash("secret"),
# >>> THIS IS NEW
"principals": ["user:alice"],
"principals": [UserPrincipal("alice")],
# <<<
},
}
Expand All @@ -80,10 +82,9 @@ class User(BaseModel):

# >>> THIS IS NEW
# just reflects the changes in the fake_user_db
principals: List[str] = []
principals: list = []
# <<<


class UserInDB(User):
hashed_password: str

Expand Down Expand Up @@ -168,14 +169,14 @@ def __acl__(self):
the function returns a list containing tuples in the form of
(Allow or Deny, principal identifier, permission name)

If a role is not listed (like "role:user") the access will be
If a role is not listed (like RolePrincipal("user")) the access will be
automatically deny. It's like a (Deny, Everyone, All) is automatically
appended at the end.
"""
return [
(Allow, Authenticated, "view"),
(Allow, "role:admin", "use"),
(Allow, f"user:{self.owner}", "use"),
(Allow, RolePrincipal("admin"), "use"),
(Allow, UserPrincipal(f"{self.owner}"), "use"),
]


Expand All @@ -189,7 +190,7 @@ class ItemListResource:

# you can even use just a list

NewItemAcl = [(Deny, "user:bob", "create"), (Allow, Authenticated, "create")]
NewItemAcl = [(Deny, UserPrincipal("bob"), "create"), (Allow, Authenticated, "create")]


# the current user is determined by the "get_current_user" function.
Expand Down
Loading