From 1164aa3c0ff3ed7ec417dc4bdc5e8e5423e9c9d7 Mon Sep 17 00:00:00 2001 From: Edd Salkield Date: Wed, 27 Jan 2021 19:43:28 +0000 Subject: [PATCH] Use polymorphic principals --- README.md | 40 ++++++++--------- fastapi_permissions/__init__.py | 67 ++++++++++++++++++++++++++--- fastapi_permissions/example.py | 17 ++++---- tests/test_permissions.py | 76 ++++++++++++++++++++++++--------- 4 files changed, 145 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 8a74114..84a994f 100644 --- a/README.md +++ b/README.md @@ -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() @@ -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)): ... @@ -87,7 +87,7 @@ 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 @@ -95,7 +95,7 @@ The system depends on a couple of concepts not found in FastAPI: 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: @@ -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ā€¦ @@ -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: @@ -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) @@ -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 diff --git a/fastapi_permissions/__init__.py b/fastapi_permissions/__init__.py index 9dbdf9b..54196e1 100644 --- a/fastapi_permissions/__init__.py +++ b/fastapi_permissions/__init__.py @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/fastapi_permissions/example.py b/fastapi_permissions/example.py index 625292a..0784294 100644 --- a/fastapi_permissions/example.py +++ b/fastapi_permissions/example.py @@ -16,6 +16,8 @@ Everyone, configure_permissions, list_permissions, + UserPrincipal, + RolePrincipal ) # >>> THIS IS NEW @@ -49,7 +51,7 @@ "email": "bob@example.com", "hashed_password": pwd_context.hash("secret"), # >>> THIS IS NEW - "principals": ["user:bob", "role:admin"], + "principals": [UserPrincipal("bob"), RolePrincipal("admin")], # <<< }, "alice": { @@ -58,7 +60,7 @@ "email": "alicechains@example.com", "hashed_password": pwd_context.hash("secret"), # >>> THIS IS NEW - "principals": ["user:alice"], + "principals": [UserPrincipal("alice")], # <<< }, } @@ -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 @@ -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"), ] @@ -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. diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 58d6a8e..5aba921 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1,9 +1,10 @@ """ Tests the main api functions """ import inspect - import pytest +from fastapi_permissions import UserPrincipal, RolePrincipal, ActionPrincipal, All + def dummy_principal_callable(): return "dummy principals" @@ -22,13 +23,15 @@ def __init__(self, principals): self.principals.append(Authenticated) def __repr__(self): - return self.principals[0] + return "".format(str(self.principals)) - -dummy_user_john = DummyUser(["user:john", "role:user"]) -dummy_user_jane = DummyUser(["user:jane", "role:user", "role:moderator"]) -dummy_user_alice = DummyUser(["user:alice", "role:admin"]) +dummy_user_john = DummyUser([UserPrincipal("john"), RolePrincipal("user")]) +dummy_user_jane = DummyUser([UserPrincipal("jane"), RolePrincipal("user"), + RolePrincipal("moderator")]) +dummy_user_alice = DummyUser([UserPrincipal("alice"), RolePrincipal("admin")]) dummy_user_bob = DummyUser([]) +dummy_user_edd = DummyUser([UserPrincipal("john"), RolePrincipal("user"), + RolePrincipal("denied"), ActionPrincipal("perform_action")]) @pytest.fixture @@ -36,15 +39,19 @@ def acl_fixture(): from fastapi_permissions import All, Deny, Allow, Everyone, Authenticated yield [ - (Allow, "user:john", "view"), - (Allow, "user:john", "edit"), - (Allow, "user:jane", ("edit", "use")), - (Deny, "role:user", "create"), - (Allow, "role:moderator", "delete"), + (Allow, UserPrincipal("john"), "view"), + (Allow, UserPrincipal("john"), "edit"), + (Allow, UserPrincipal("jane"), ("edit", "use")), + (Deny, RolePrincipal("user"), "create"), + (Allow, RolePrincipal("moderator"), "delete"), (Deny, Authenticated, "copy"), - (Allow, "role:admin", All), + (Allow, RolePrincipal("admin"), All), + (Deny, RolePrincipal("admin"), All), (Allow, Everyone, "share"), - (Allow, "role:moderator", "share"), + (Deny, RolePrincipal("denied"), All), + (Allow, RolePrincipal("denied"), All), + (Allow, RolePrincipal("moderator"), "share"), + (Allow, ActionPrincipal("perform_action"), "act"), ] @@ -57,7 +64,7 @@ def acl_fixture(): "delete": False, "share": True, "copy": False, - "permissions:*": False, + "act": False, }, dummy_user_jane: { "view": False, @@ -67,7 +74,7 @@ def acl_fixture(): "delete": True, "share": True, "copy": False, - "permissions:*": False, + "act": False, }, dummy_user_alice: { "view": True, @@ -77,7 +84,7 @@ def acl_fixture(): "delete": True, "share": True, "copy": False, - "permissions:*": True, + "act": True, }, dummy_user_bob: { "view": False, @@ -87,10 +94,28 @@ def acl_fixture(): "delete": False, "share": True, "copy": False, - "permissions:*": False, + "act": False, + }, + dummy_user_edd: { + "view": True, + "edit": True, + "use": False, + "create": False, + "delete": False, + "share": False, + "copy": False, + "act": True, }, } +has_all_permission = { + dummy_user_john: False, + dummy_user_jane: False, + dummy_user_alice: True, + dummy_user_bob: False, + dummy_user_edd: False, +} + def test_configure_permissions_wraps_principal_callable(mocker): """ test if active_principle_funcs parameter is wrapped in "Depends" """ @@ -243,7 +268,7 @@ def test_permission_dependency_raises_exception(mocker): ) @pytest.mark.parametrize( "permission", - ["view", "edit", "use", "create", "delete", "share", "copy", "nuke"], + ["view", "edit", "use", "create", "delete", "share", "copy", "nuke", "act"], ) def test_has_permission(user, permission, acl_fixture): """ tests the has_permission function """ @@ -251,8 +276,10 @@ def test_has_permission(user, permission, acl_fixture): result = has_permission(user.principals, permission, acl_fixture) - key = "permissions:*" if permission == "nuke" else permission - assert result == permission_results[user][key] + if permission == "nuke": + assert result == has_all_permission[user] + else: + assert result == permission_results[user][permission] @pytest.mark.parametrize( @@ -265,4 +292,13 @@ def test_list_permissions(user, acl_fixture): result = list_permissions(user.principals, acl_fixture) + assert result.default == has_all_permission[user] assert result == permission_results[user] + + +@pytest.mark.asyncio +async def test_permission_set_creation(): + """ raise an error if permission set repr fails """ + from fastapi_permissions import PermissionSet + p = PermissionSet(default=True) + assert p.__repr__() == "PermissionSet({}, default=True)"