├── tests ├── __init__.py ├── conftest.py ├── test_permission.py ├── fixtures.py ├── test_role.py └── test_policy.py ├── MANIFEST.in ├── requirements-testing.txt ├── tox.ini ├── CHANGES.rst ├── .travis.yml ├── balrog ├── __init__.py ├── exceptions.py ├── permission.py ├── role.py └── policy.py ├── .gitignore ├── LICENSE.txt ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Register common fixtures.""" 2 | 3 | from .fixtures import * 4 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | mock 2 | pytest-cache 3 | pytest-cov 4 | pytest-pep8 5 | pytest-xdist 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | distshare = {homedir}/.tox/distshare 3 | envlist = py26,py27,py33,py34 4 | 5 | [testenv] 6 | commands = py.test --pep8 --junitxml={envlogdir}/junit-{envname}.xml balrog tests 7 | deps = -r{toxinidir}/requirements-testing.txt 8 | 9 | [pytest] 10 | addopts = -vv -l 11 | pep8maxlinelength = 120 12 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.1.0 5 | ----- 6 | 7 | - Policy will keep track of all the permissions and raise PermissionNotFound when called with unknown permission (hvdklauw) 8 | 9 | 1.0.1 10 | ----- 11 | 12 | - Pass the context to the get_identity (olegpidsadnyi) 13 | 14 | 1.0.0 15 | ----- 16 | 17 | * Initial public release 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | # command to install dependencies 4 | install: 5 | - pip install tox python-coveralls 6 | # # command to run tests 7 | script: tox 8 | after_success: 9 | - pip install -r requirements-testing.txt -e . 10 | - py.test --cov=balrog --cov-report=term-missing tests 11 | - coveralls 12 | notifications: 13 | email: 14 | - opensource-tests@paylogic.com 15 | -------------------------------------------------------------------------------- /balrog/__init__.py: -------------------------------------------------------------------------------- 1 | """Balrog public API.""" 2 | 3 | __version__ = '1.1.0' 4 | 5 | try: 6 | from balrog.policy import Policy 7 | from balrog.role import Role 8 | from balrog.permission import Permission 9 | from balrog.exceptions import Error, PermissionNotFound, RoleNotFound 10 | 11 | __all__ = ['Policy', 'Role', 'Permission', 'Error', 'PermissionNotFound', 'RoleNotFound'] 12 | except ImportError: 13 | # avoid import errors when only __version__ is needed (for setup.py) 14 | pass 15 | -------------------------------------------------------------------------------- /tests/test_permission.py: -------------------------------------------------------------------------------- 1 | """Permission related tests.""" 2 | 3 | 4 | def test_create(permission, permission_name): 5 | """Test permission creation.""" 6 | assert permission.name == permission_name 7 | 8 | 9 | def test_check(permission, identity): 10 | """Test Permission.check is True by default.""" 11 | assert permission.check(identity) 12 | 13 | 14 | def test_filter(permission, identity, objects): 15 | """Test Permission.filter bypasses the objects by default.""" 16 | assert permission.filter(identity, objects) == objects 17 | -------------------------------------------------------------------------------- /balrog/exceptions.py: -------------------------------------------------------------------------------- 1 | """Access control exceptions.""" 2 | 3 | 4 | class Error(Exception): 5 | 6 | """Base exception class. 7 | 8 | All errors extend this class which is useful to dispatch in error handlers. 9 | """ 10 | 11 | 12 | class PermissionNotFound(Error): 13 | 14 | """Permission is not found. 15 | 16 | Can't filter objects because there's no permission that implements filtering. 17 | """ 18 | 19 | 20 | class RoleNotFound(Error): 21 | 22 | """Role is not found. 23 | 24 | Role is not found for the name returned by the get_role callback. 25 | """ 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # SublimeText 56 | *.sublime-project 57 | *.sublime-workspace 58 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Paylogic International 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /balrog/permission.py: -------------------------------------------------------------------------------- 1 | """Access control permission.""" 2 | 3 | 4 | class Permission(object): 5 | 6 | """Permission that identity can have.""" 7 | 8 | def __init__(self, name): 9 | """Create permission. 10 | 11 | :param name: Unique permission name within one role. 12 | """ 13 | self.name = name 14 | 15 | def check(self, identity, *args, **kwargs): 16 | """Check if permission applies to this identity. 17 | 18 | :param identity: Currently authenticated identity. 19 | 20 | :return: `True` if permission is granted to requested identity. 21 | :note: This function could be overridden in order to do additional check 22 | in the certain context. 23 | """ 24 | return True 25 | 26 | def filter(self, identity, objects, *args, **kwargs): 27 | """Filter objects according to this permission and identity. 28 | 29 | :param identity: Currently authenticated identity. 30 | :param objects: Objects to filter out. 31 | 32 | :returns: Filtered objects. 33 | :note: This function should be overridden in order to implement the 34 | filtering in the certain context. 35 | """ 36 | return objects 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Balrog library setup file.""" 3 | 4 | import os 5 | import codecs 6 | 7 | from setuptools import setup 8 | 9 | import balrog 10 | 11 | 12 | long_description = [] 13 | 14 | for text_file in ['README.rst', 'CHANGES.rst']: 15 | with codecs.open(os.path.join(os.path.dirname(os.path.abspath(__file__)), text_file), encoding="utf-8") as f: 16 | long_description.append(f.read()) 17 | 18 | setup( 19 | name="balrog", 20 | description="Python access control library.", 21 | long_description='\n'.join(long_description), 22 | author="Paylogic International", 23 | license="MIT license", 24 | author_email="developers@paylogic.com", 25 | url="https://github.com/paylogic/balrog", 26 | version=balrog.__version__, 27 | classifiers=[ 28 | "Development Status :: 6 - Mature", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: POSIX", 32 | "Operating System :: Microsoft :: Windows", 33 | "Operating System :: MacOS :: MacOS X", 34 | "Topic :: Software Development :: Testing", 35 | "Topic :: Software Development :: Libraries", 36 | "Topic :: Utilities", 37 | "Programming Language :: Python :: 2", 38 | "Programming Language :: Python :: 3" 39 | ] + [("Programming Language :: Python :: %s" % x) for x in "2.6 2.7 3.4".split()], 40 | packages=["balrog"], 41 | tests_require=["tox"], 42 | 43 | ) 44 | -------------------------------------------------------------------------------- /balrog/role.py: -------------------------------------------------------------------------------- 1 | """Access control role.""" 2 | 3 | from balrog import exceptions 4 | 5 | 6 | class Role(object): 7 | 8 | """Role, a set of permissions that identity can have access to.""" 9 | 10 | def __init__(self, name, permissions): 11 | """Create a role. 12 | 13 | :param name: Unique role name within one policy. 14 | :param permissions: Permissions of the role. 15 | """ 16 | self.name = name 17 | self.permissions = {} 18 | 19 | for permission in permissions: 20 | assert permission.name not in self.permissions, ( 21 | 'The permission `{0}` is already registered within this role.'.format(permission.name) 22 | ) 23 | self.permissions[permission.name] = permission 24 | 25 | def check(self, identity, permission, *args, **kwargs): 26 | """Check if the identity has requested permission. 27 | 28 | :param identity: Currently authenticated identity. 29 | :param permission: Permission name. 30 | 31 | :return: True if identity role has this permission. 32 | """ 33 | try: 34 | permission = self.permissions[permission] 35 | except KeyError: 36 | return False 37 | else: 38 | return permission.check(identity, *args, **kwargs) 39 | 40 | def filter(self, identity, permission, objects, *args, **kwargs): 41 | """Filter objects according to the permission this identity has. 42 | 43 | :param identity: Currently authenticated identity. 44 | :param permission: Permission name. 45 | :param objects: Objects to filter out. 46 | 47 | :returns: Filtered objects. 48 | :raises: `PermissionNotFound` when no permission is found that can 49 | filter the objects. 50 | """ 51 | try: 52 | permission = self.permissions[permission] 53 | except KeyError: 54 | raise exceptions.PermissionNotFound() 55 | else: 56 | return permission.filter(identity, objects, *args, **kwargs) 57 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | """Common fixtures.""" 2 | 3 | import pytest 4 | import balrog 5 | 6 | 7 | class User(object): 8 | 9 | """Test identity.""" 10 | 11 | name = 'user' 12 | 13 | 14 | @pytest.fixture 15 | def permission_name(): 16 | """Permission name.""" 17 | return 'test-balrog' 18 | 19 | 20 | @pytest.fixture 21 | def permission(permission_name): 22 | """Permission object.""" 23 | return balrog.Permission(name=permission_name) 24 | 25 | 26 | @pytest.fixture 27 | def identity(identity_role): 28 | """Identity object.""" 29 | user = User() 30 | user.role = identity_role 31 | return user 32 | 33 | 34 | @pytest.fixture 35 | def objects(): 36 | """The objects to filter.""" 37 | return [1, 2, 3] 38 | 39 | 40 | @pytest.fixture 41 | def role_name(): 42 | """Role name.""" 43 | return 'test-role' 44 | 45 | 46 | @pytest.fixture 47 | def role_permissions(permission): 48 | """Role permissions.""" 49 | return [permission] 50 | 51 | 52 | @pytest.fixture 53 | def role(role_name, role_permissions): 54 | """Role object.""" 55 | return balrog.Role(name=role_name, permissions=role_permissions) 56 | 57 | 58 | @pytest.fixture 59 | def identity_role(role): 60 | """The role to use for the identity.""" 61 | return role 62 | 63 | 64 | @pytest.fixture 65 | def policy_roles(role): 66 | """Policy roles.""" 67 | return [role] 68 | 69 | 70 | @pytest.fixture 71 | def get_identity(identity): 72 | """Get identity policy callback.""" 73 | def _get_identity(*args, **kwargs): 74 | return identity 75 | return _get_identity 76 | 77 | 78 | @pytest.fixture 79 | def get_role(): 80 | """Get role policy callback.""" 81 | def _get_role(identity, *args, **kwargs): 82 | try: 83 | return identity.role.name 84 | except AttributeError: 85 | return 86 | return _get_role 87 | 88 | 89 | @pytest.fixture 90 | def policy(policy_roles, get_identity, get_role): 91 | """Role object.""" 92 | return balrog.Policy( 93 | roles=policy_roles, 94 | get_identity=get_identity, 95 | get_role=get_role 96 | ) 97 | -------------------------------------------------------------------------------- /tests/test_role.py: -------------------------------------------------------------------------------- 1 | """Role related tests.""" 2 | 3 | import pytest 4 | import mock 5 | import balrog 6 | 7 | 8 | def test_create(role, role_name, role_permissions): 9 | """Test role creation.""" 10 | assert role.name == role_name 11 | assert role.permissions == dict((perm.name, perm) for perm in role_permissions) 12 | 13 | 14 | def test_permission_name_is_unique(role_name, permission): 15 | """Test that permissions are registered with unique name.""" 16 | with pytest.raises(AssertionError): 17 | balrog.Role( 18 | name=role_name, 19 | permissions=( 20 | permission, 21 | permission, 22 | ), 23 | ) 24 | 25 | 26 | def test_check(role, identity, permission_name): 27 | """Test Role.check.""" 28 | with mock.patch.object(balrog.Permission, 'check') as mock_check: 29 | mock_check.return_value = True 30 | assert role.check(identity, permission_name, 1, 2, 3, a=1, b=2, c=3) 31 | mock_check.assert_called_once_with(identity, 1, 2, 3, a=1, b=2, c=3) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | 'name', 36 | ( 37 | 'unknown', 38 | None, 39 | -1, 40 | ), 41 | ) 42 | def test_check_permission_not_found(role, identity, permission_name, name): 43 | """Test Role.check is False when permission is not found.""" 44 | assert name != permission_name 45 | assert not role.check(identity, name) 46 | 47 | 48 | def test_filter(role, identity, permission_name, objects): 49 | """Test Role.filter bypasses the objects by default.""" 50 | with mock.patch.object(balrog.Permission, 'filter') as mock_filter: 51 | mock_filter.return_value = objects 52 | # None is passed here for default explicitly in order the call args to match 53 | assert role.filter(identity, permission_name, objects, 1, 2, 3, a=1, b=2, c=3) == objects 54 | mock_filter.assert_called_once_with(identity, objects, 1, 2, 3, a=1, b=2, c=3) 55 | 56 | 57 | def test_filter_without_permission(role, identity, permission_name, objects): 58 | """Test Role.filter raises an exception when not allowed.""" 59 | with pytest.raises(balrog.PermissionNotFound): 60 | role.filter(identity, 'unknown', objects) 61 | -------------------------------------------------------------------------------- /tests/test_policy.py: -------------------------------------------------------------------------------- 1 | """Policy related tests.""" 2 | 3 | import pytest 4 | import mock 5 | import balrog 6 | 7 | 8 | def test_create(policy, policy_roles): 9 | """Test policy creation.""" 10 | assert policy.roles == dict((role.name, role) for role in policy_roles) 11 | permissions = {} 12 | for role in policy_roles: 13 | for name, permission in role.permissions.items(): 14 | permissions[name] = permission 15 | assert policy.permissions == permissions 16 | 17 | 18 | def test_role_name_is_unique(role, get_role, get_identity): 19 | """Test that roles are registered with unique name.""" 20 | with pytest.raises(AssertionError): 21 | balrog.Policy( 22 | roles=( 23 | role, 24 | role, 25 | ), 26 | get_role=get_role, 27 | get_identity=get_identity, 28 | ) 29 | 30 | 31 | def test_check(policy, permission_name, identity): 32 | """Test Policy.check.""" 33 | with mock.patch.object(balrog.Role, 'check') as mock_check: 34 | mock_check.return_value = True 35 | assert policy.check(permission_name, 1, 2, 3, a=1, b=2, c=3) 36 | mock_check.assert_called_once_with(identity, permission_name, 1, 2, 3, a=1, b=2, c=3) 37 | 38 | 39 | @pytest.mark.parametrize( 40 | 'name', 41 | ( 42 | 'unknown', 43 | None, 44 | -1, 45 | ), 46 | ) 47 | def test_check_permission_not_found(policy, permission_name, name): 48 | """Test Policy.check is False when permission is not found.""" 49 | assert name != permission_name 50 | with pytest.raises(balrog.PermissionNotFound): 51 | policy.check(name) 52 | 53 | 54 | @pytest.mark.parametrize( 55 | 'identity_role', 56 | ( 57 | None, 58 | ) 59 | ) 60 | def test_check_role_not_found(policy, permission_name, identity_role): 61 | """Test Policy.check raises RoleNotFound when role is not found.""" 62 | with pytest.raises(balrog.RoleNotFound): 63 | policy.check(permission_name) 64 | 65 | 66 | def test_filter(policy, identity, permission_name, objects): 67 | """Test Policy.filter bypasses the objects by default.""" 68 | with mock.patch.object(balrog.Role, 'filter') as mock_filter: 69 | mock_filter.return_value = objects 70 | # None is passed here for default explicitly in order the call args to match 71 | assert policy.filter(permission_name, objects, 1, 2, 3, a=1, b=2, c=3) == objects 72 | mock_filter.assert_called_once_with(identity, permission_name, objects, 1, 2, 3, a=1, b=2, c=3) 73 | 74 | 75 | def test_filter_without_permission(policy, identity, permission_name, objects): 76 | """Test Policy.filter raises an exception when not allowed.""" 77 | with pytest.raises(balrog.PermissionNotFound): 78 | policy.filter('unknown', objects) 79 | 80 | 81 | def test_get_role(policy, identity): 82 | """Test get_role raises an exception for unknown role name.""" 83 | with pytest.raises(balrog.RoleNotFound): 84 | with mock.patch.object(policy, '_get_role') as mock_get_role: 85 | mock_get_role.return_value = 'unknown' 86 | policy.get_role(identity) 87 | -------------------------------------------------------------------------------- /balrog/policy.py: -------------------------------------------------------------------------------- 1 | """Access control policy.""" 2 | 3 | from balrog import exceptions 4 | 5 | 6 | class Policy(object): 7 | 8 | """Controls the access of a certain actor to a certain action on a resource.""" 9 | 10 | def __init__(self, roles, get_identity, get_role): 11 | """Create and configure access control. 12 | 13 | :param roles: All roles of this access control. 14 | :param get_identity: Callable that returns the currently authenticated 15 | identity. 16 | :param get_role: Callable that returns role name of the currently authenticated 17 | identity. 18 | """ 19 | self._get_identity = get_identity 20 | self._get_role = get_role 21 | self.roles = {} 22 | self.permissions = {} 23 | 24 | for role in roles: 25 | assert role.name not in self.roles, ( 26 | u'The role `{0}` is already registered.'.format(role.name) 27 | ) 28 | self.roles[role.name] = role 29 | for name, permission in role.permissions.items(): 30 | if name not in self.permissions: 31 | self.permissions[name] = permission 32 | 33 | def _check_permission(self, permission): 34 | """Check if the given permission exists in the list of permissions. 35 | 36 | :param permission: The permission to check. 37 | 38 | :raises: `PermissionNotFound` if the permissions wasn't found. 39 | """ 40 | if self.permissions and permission not in self.permissions: 41 | raise exceptions.PermissionNotFound( 42 | 'Permission {0} was not found in the list of all permissions.'.format(permission) 43 | ) 44 | 45 | def get_identity(self, *args, **kwargs): 46 | """Get current identity. 47 | 48 | :returns: An identity object which can be provided via a callback. 49 | """ 50 | return self._get_identity(*args, **kwargs) 51 | 52 | def get_role(self, identity, *args, **kwargs): 53 | """Get identity role. 54 | 55 | :returns: Identity role object which name can be provided via a callback. 56 | :raises: `RoleNotFound` if no role found for this identity. 57 | """ 58 | name = self._get_role(identity, *args, **kwargs) 59 | 60 | try: 61 | return self.roles[name] 62 | except KeyError: 63 | raise exceptions.RoleNotFound(name) 64 | 65 | def check(self, permission, *args, **kwargs): 66 | """Check if the identity has requested permission. 67 | 68 | :param permission: Permission name. 69 | :return: `True` if identity role has this permission. 70 | :raises: `RoleNotFound` if no role was found. 71 | :raises: `PermissionNotFound` when no permission is found. 72 | """ 73 | self._check_permission(permission) 74 | identity = self.get_identity(*args, **kwargs) 75 | role = self.get_role(identity, *args, **kwargs) 76 | return role.check(identity, permission, *args, **kwargs) 77 | 78 | def filter(self, permission, objects, *args, **kwargs): 79 | """Filter objects according to the permission this identity has. 80 | 81 | :param permission: Permission name. 82 | :param objects: Objects to filter out. 83 | :returns: Filtered objects. 84 | :raises: `RoleNotFound` if no role found for this identity. 85 | :raises: `PermissionNotFound` when no permission is found that can 86 | filter the objects. 87 | """ 88 | self._check_permission(permission) 89 | identity = self.get_identity(*args, **kwargs) 90 | role = self.get_role(identity, *args, **kwargs) 91 | return role.filter(identity, permission, objects, *args, **kwargs) 92 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Balrog 2 | ====== 3 | 4 | Balrog is a Python library that helps you to build an authorization system in your projects: 5 | 6 | :: 7 | 8 | You shall not pass! 9 | 10 | 11 | Balrog is good for systems with statically defined roles that enable certain workflows. 12 | Every identity can have only one role on the certain context. This approach allows covering 13 | your system with functional tests according to the roles and flows these roles perform. 14 | Formal requirements can be applied to the workflows in the system which will define roles. 15 | 16 | These roles are statically defined in the code and this way properly versioned and covered 17 | with testing. It is possible to do a composition of certain permission groups and share them 18 | between roles, but semantically there's no way for one identity to have 2 contradicting 19 | roles when one role forbids actions and the other allows them. Instead a proper role 20 | can be extracted with the permissions it allows. 21 | 22 | 23 | Installation 24 | ------------ 25 | 26 | .. code-block:: sh 27 | 28 | pip install balrog 29 | 30 | 31 | Usage 32 | ----- 33 | 34 | Permission is needed to access a resource or to perform an action. Permissions are grouped in the roles 35 | and roles are grouped in the policies. 36 | 37 | The entry point where a permission is being checked is the Policy. Define an instance of the Policy 38 | and specify the list of roles it works with. 39 | 40 | Project can contain multiple policies that serve different purposes. 41 | 42 | Permission declaration: 43 | 44 | .. code-block:: python 45 | 46 | 47 | import balrog 48 | from flask import request 49 | 50 | def get_identity(*args, **kwargs): 51 | """Get current user.""" 52 | # Flask request wrapper implements the ``user`` property 53 | return request.user 54 | 55 | def get_role(identity, *args, **kwargs): 56 | """Get current identity role.""" 57 | # User.role is a property of the ORM User model 58 | return identity.role 59 | 60 | 61 | read = balrog.Permission(name="article.read") 62 | post = balrog.Permission(name="article.post") 63 | comment = balrog.Permission(name="article.comment") 64 | 65 | anonymous = balrog.Role( 66 | name="anonymous", 67 | permissions=[read], 68 | ) 69 | """Anonymous visitors can read articles.""" 70 | 71 | user = balrog.Role( 72 | name="user", 73 | permissions=[read, comment], 74 | ) 75 | """User accounts can read and comment articles.""" 76 | 77 | author = balrog.Role( 78 | name="author", 79 | permissions=[read, post, comment], 80 | ) 81 | """Author accounts can read, create and comment articles.""" 82 | 83 | policy = balrog.Policy( 84 | roles=[anonymous, user, author], 85 | get_identity=get_identity, 86 | get_role=get_role, 87 | ) 88 | 89 | 90 | Permission checking: 91 | 92 | .. code-block:: python 93 | 94 | # ... 95 | policy = balrog.Policy(roles=[anonymous, user, author], get_identity=get_identity, get_role=get_role) 96 | policy.check("article.comment") 97 | 98 | 99 | Filtering collections: 100 | 101 | .. code-block:: python 102 | 103 | articles = session.query(Article) 104 | my_articles = policy.filter("article.view", objects=articles) 105 | 106 | 107 | Every role is a collection of permissions. Besides being included in the role permissions can 108 | implement even more detailed checking and filtering logic. 109 | 110 | 111 | Permission 112 | ---------- 113 | 114 | Permissions have unique names (within the role) which reflect the resource and the action you 115 | want to take with this resource. 116 | 117 | .. code-block:: python 118 | 119 | import balrog 120 | 121 | eat = balrog.Permission(name="cucumber.eat") 122 | happy = balrog.Permission(name="be-happy") 123 | 124 | 125 | Name is just a string identifier that you are using in order to ask a policy for a permission. 126 | The name formatting convention can be decided per project. 127 | 128 | Permissions have 2 methods: ``check`` and ``filter``. By default the ``check`` method implements ``True`` 129 | and the ``filter`` method is simply bypassing the objects. These methods are an additional opportunities 130 | to control the access to certain context, instances of your resources, check whitelists, filter out objects 131 | from collections that can not be seen by currently authenticated identity, etc. 132 | 133 | 134 | Role 135 | ---- 136 | 137 | Roles have unique names within the policy. Role name is determined by the authenticated identity 138 | and used in the policy permission check implicitly. 139 | 140 | Roles are collections of permissions that define the role and enable certain workflows in your 141 | system. 142 | 143 | When a system is large and has a lot of specific permissions declared sometimes it is easier to 144 | subclass the Role class instead of granting all permissions to the role: 145 | 146 | .. code-block:: python 147 | 148 | import balrog 149 | 150 | 151 | class Admin(balrog.Role): 152 | 153 | def check(self, identity, permission, *args, **kwargs): 154 | return True 155 | 156 | 157 | 158 | Policy 159 | ------ 160 | 161 | Policy is used as an entry point of permission checking in your project. It incapsulates the roles 162 | that define your workflows. There could be multiple policy instances in the project. 163 | 164 | Besides roles policy requires some configuration and backend implementation: 165 | 166 | get_identity 167 | ~~~~~~~~~~~~ 168 | 169 | A callback that returns currently authenticated identity. Projects have to implement this backend 170 | and restore the identity instance (e.g. User object) for example from the Flask Request object. 171 | 172 | .. code-block:: python 173 | 174 | from flask import request 175 | 176 | def get_identity(*args, **kwargs): 177 | """Get current user.""" 178 | # Flask request wrapper implements the ``user`` property 179 | return request.user 180 | 181 | 182 | 183 | get_role 184 | ~~~~~~~~ 185 | 186 | A callback that returns which role current identity has on the context. In the simple case the role is associated 187 | to the user in the database. 188 | 189 | 190 | .. code-block:: python 191 | 192 | def get_role(identity, *args, **kwargs): 193 | """Get current identity role.""" 194 | # User.role is a property of the ORM User model 195 | return identity.role 196 | 197 | 198 | check 199 | ~~~~~ 200 | 201 | The permission check. All arguments that you pass to this function are passed along in Role.check and finally 202 | to Permission.check. 203 | 204 | .. code-block:: python 205 | 206 | if not policy.check("article.read", article=a): 207 | flask.abort("You can't access the article `{0}`".format(a.id)) 208 | 209 | filter 210 | ~~~~~~ 211 | 212 | The function that is filtering out items of the given objects if the identity has no permission to access them. 213 | 214 | 215 | .. code-block:: python 216 | 217 | articles = session.query(Article).filter_by(is_published=True) 218 | 219 | my_articles = policy.filter("article.read", objects=articles) 220 | 221 | 222 | Implementing your own filtering: 223 | 224 | .. code-block:: python 225 | 226 | import balrog 227 | 228 | class ViewArticle(balrog.Permission); 229 | 230 | def filter(self, identity, objects, *args, **kwargs): 231 | """Filter out articles of the other users. 232 | 233 | :param identity: User object. 234 | :param objects: SQLAlchemy query. 235 | 236 | :returns: SQLAlchemy query with applied filtering. 237 | """ 238 | return objects.filter_by(user_id=identity.id) 239 | 240 | 241 | Filter function can raise an exception in the case when there's no such permission 242 | in the role of the identity. In this case the library doesn't know for sure what type to 243 | return that represents an empty collection of objects. Some projects would expect 244 | an empty list, some - falsy ORM query, etc. Instead the exception should be handled: 245 | 246 | 247 | .. code-block:: python 248 | 249 | try: 250 | my_articles = policy.filter("article.read", objects=articles) 251 | except balrog.PermissionNotFound: 252 | my_articles = [] 253 | 254 | 255 | context 256 | ~~~~~~~ 257 | 258 | Everything that you pass extra to the check or filter function is passed along to the regarding 259 | Role and Permission methods. 260 | You can pass certain instance of an object you control your access using whitelists. 261 | 262 | .. code-block:: python 263 | 264 | policy.check("message.send", ip=ip_addr) 265 | 266 | 267 | Policy.check method can compare if ip address is in a whitelist. 268 | 269 | 270 | Contact 271 | ------- 272 | 273 | If you have questions, bug reports, suggestions, etc. please create an issue on 274 | the `GitHub project page `_. 275 | 276 | 277 | License 278 | ------- 279 | 280 | This software is licensed under the `MIT license `_ 281 | 282 | See `License `_ --------------------------------------------------------------------------------