├── .flake8 ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── PYPI_README.md ├── README.rst ├── django_prbac ├── __init__.py ├── admin.py ├── arbitrary.py ├── csv.py ├── decorators.py ├── exceptions.py ├── fields.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── mock_settings.py ├── models.py ├── tests │ ├── __init__.py │ ├── test_decorators.py │ ├── test_fields.py │ ├── test_forms.py │ └── test_models.py ├── urls.py └── utils.py ├── docs ├── Makefile ├── apidoc │ ├── django_prbac.migrations.rst │ ├── django_prbac.rst │ ├── django_prbac.tests.rst │ └── modules.rst ├── conf.py ├── index.rst ├── make.bat ├── setup.rst └── tutorial.rst ├── pyproject.toml └── setup.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 115 3 | ignore = E128, E265, F403, W503 # http://pep8.readthedocs.org/en/latest/intro.html#error-codes 4 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: django-prbac test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | 13 | strategy: 14 | matrix: 15 | python-version: [ "3.8", "3.9", "3.10", "3.11" ] 16 | django-version: [ "3.2.*", "4.2.*" ] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: install dependencies 24 | run: | 25 | pip install django==${{ matrix.django-version }} 26 | pip install -e . 27 | pip install coverage coveralls 28 | - name: run tests 29 | run: | 30 | coverage run --source='django_prbac' `which django-admin` test django_prbac --settings django_prbac.mock_settings --traceback 31 | - name: report coverage stats 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | coverage report 36 | coveralls --service=github 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *~ 3 | \#* 4 | .\#* 5 | .history 6 | .mypy_cache 7 | .vscode 8 | 9 | # README.txt is just for the Cheeseshop; README.md is the authoritative one 10 | /README.txt 11 | /commcare_export/VERSION 12 | 13 | # OSX droppings 14 | .DS_Store 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Packages 20 | *.egg 21 | *.egg-info 22 | dist 23 | build 24 | eggs 25 | parts 26 | bin 27 | var 28 | sdist 29 | develop-eggs 30 | .installed.cfg 31 | lib 32 | lib64 33 | /pip-wheel-metadata 34 | 35 | # Installer logs 36 | pip-log.txt 37 | 38 | # Unit test / coverage reports 39 | .coverage 40 | .tox 41 | nosetests.xml 42 | 43 | # Translations 44 | *.mo 45 | 46 | # Mr Developer 47 | .mr.developer.cfg 48 | .project 49 | .pydevproject 50 | 51 | # PyCharm 52 | .idea 53 | 54 | # Excel 55 | ~*.xlsx 56 | 57 | /docs/_build 58 | /django-prbac.db 59 | 60 | # virtualenv 61 | .python-version 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # django-prbac changelog 2 | 3 | ## v1.1.0 - 2023-11-28 4 | - Add support for Django 4.2 LTS 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2012, Dimagi Inc., and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of CommCare HQ, CommTrack, CommConnect, or Dimagi, nor the 12 | names of its contributors, may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL DIMAGI INC. BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /PYPI_README.md: -------------------------------------------------------------------------------- 1 | # Releasing on PyPI 2 | 3 | We follow https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/ 4 | (Oct 2019 version). 5 | 6 | 7 | ## One-time setup 8 | 9 | Make sure your `~/.pypirc` is set up like this 10 | 11 | ```bash 12 | [distutils] 13 | index-servers= 14 | pypi 15 | test 16 | 17 | [test] 18 | repository = https://test.pypi.org/legacy/ 19 | username = 20 | 21 | [pypi] 22 | username = __token__ 23 | ``` 24 | 25 | ## Build 26 | ```bash 27 | rm -rf build dist 28 | python -m pep517.build . 29 | ``` 30 | 31 | ## Push to PyPI staging server 32 | 33 | ```bash 34 | twine upload -r test --sign dist/* 35 | ``` 36 | 37 | In a different virtualenv, test that you can install it: 38 | 39 | ```bash 40 | pip install -i https://testpypi.python.org/pypi django-prbac --upgrade 41 | ``` 42 | 43 | 44 | ## Push to PyPI 45 | 46 | ```bash 47 | twine upload -r pypi --sign dist/* 48 | ``` 49 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django PRBAC 2 | ============ 3 | 4 | (Parameterized Role-Based Access Control) 5 | 6 | https://github.com/dimagi/django-prbac 7 | 8 | |Build Status| |Test coverage| |PyPi version| 9 | 10 | About RBAC and PRBAC 11 | -------------------- 12 | 13 | **Role-based access control (RBAC)** is the standard method for access control in large systems. 14 | With RBAC, you grant *privileges* to *roles*. For example you 15 | might grant the privilege ``Reporting`` to the role ``Analyst``. In most 16 | systems, you can nest roles as deeply as you want, and give users however many roles. A good 17 | example of this in practice is `PostgreSQL roles and privileges 18 | `_. 19 | 20 | The roles and privileges are whatever abstract concepts make sense for your system. It is up 21 | to application code to determine what actions to take based on the privileges granted. You can 22 | use django-prbac to implement lower level concepts such as row-level or object-level access 23 | control. 24 | 25 | **Parameterized role-based access control (PRBAC)** adds parameters 26 | to roles and privileges. Now, for example, you might grant ``"Reporting(organization="Dimagi",area="Finance")`` 27 | to ``FinancialAnalyst(organization="Dimagi")``. If you don't use parameters, then it is just RBAC. 28 | If you use parameters with finite sets of choice, then it is exponentially more powerful. If you 29 | use parameters with infinitely many choices (such as strings or integers) then it is 30 | infinitely more powerful. A good example of limited parameterization is how particular privileges 31 | (``SELECT``, ``UPDATE``, etc) in PostgreSQL may be parameterized by an object. In PRBAC 32 | this parameterization is pervasive. 33 | 34 | 35 | In-depth documentation 36 | ---------------------- 37 | 38 | To learn more about parameterized role-based access control as implemented in this library, please 39 | visit http://django-prbac.readthedocs.org/ 40 | 41 | 42 | Access Control for Django 43 | ------------------------- 44 | 45 | * `django.contrib.auth `_: This app, shipped with Django, provides unix-style access control (users, groups, permissions) 46 | with an extensible set of permissions that are implicitly parameterized by a content type. This is 47 | fundamentally different than role-based access control. It is only worth mentioning because it comes 48 | with Django and everyone is going to want to know "why did you reimplement the wheel?". If ``django.contrib.auth`` 49 | is the wheel, then RBAC is the car and PRBAC is a transformer. I leave it as an exercise to the reader to 50 | attempt to implement PRBAC using ``django.contrib.auth`` :-) 51 | 52 | * `django-rbac `_: This project appears defunct and is not 53 | parameterized in any rate. 54 | 55 | * `django-role-permissions `_: This app implements a sort of 56 | RBAC where roles are statically defined in code. 57 | 58 | * Others can be perused at https://www.djangopackages.com/grids/g/perms/. Many offer object-level permissions, 59 | which is as orthogonal to role-based access control as unix permissions. In fact, this is probably true of 60 | anything using the term "permissions". 61 | 62 | 63 | Quick Start 64 | ----------- 65 | 66 | To install, use pip: 67 | 68 | :: 69 | 70 | $ pip install django-prbac 71 | 72 | License 73 | ------- 74 | 75 | Django-prbac is distributed under the MIT license. (See the LICENSE file for details) 76 | 77 | .. |Build Status| image:: https://github.com/dimagi/django-prbac/actions/workflows/tests.yml/badge.svg 78 | :target: https://github.com/dimagi/django-prbac/actions/workflows/tests.yml 79 | .. |Test coverage| image:: https://coveralls.io/repos/dimagi/django-prbac/badge.png?branch=master 80 | :target: https://coveralls.io/r/dimagi/django-prbac 81 | .. |PyPi version| image:: https://img.shields.io/pypi/v/django-prbac.svg 82 | :target: https://pypi.python.org/pypi/django-prbac 83 | .. |PyPi downloads| image:: https://img.shields.io/pypi/dm/django-prbac.svg 84 | :target: https://pypi.python.org/pypi/django-prbac 85 | -------------------------------------------------------------------------------- /django_prbac/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.0' 2 | -------------------------------------------------------------------------------- /django_prbac/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | 4 | import simplejson 5 | 6 | import django_prbac.csv 7 | from django_prbac.models import * 8 | from django_prbac.forms import StringSetInput 9 | 10 | __all__ = [ 11 | 'RoleAdmin', 12 | 'RoleAdminForm', 13 | 'GrantAdmin', 14 | ] 15 | 16 | 17 | class RoleAdminForm(forms.ModelForm): 18 | class Meta: 19 | model = Role 20 | widgets = { 21 | 'parameters': StringSetInput 22 | } 23 | exclude = [] 24 | 25 | 26 | class RoleAdmin(admin.ModelAdmin): 27 | 28 | model = Role 29 | form = RoleAdminForm 30 | 31 | def parameters__csv(self, instance): 32 | return django_prbac.csv.line_to_string(sorted(list(instance.parameters))) 33 | parameters__csv.short_description = 'Parameters' 34 | parameters__csv.admin_order_field = 'parameters' 35 | 36 | list_display = [ 37 | 'slug', 38 | 'name', 39 | 'parameters__csv', 40 | 'description', 41 | ] 42 | 43 | search_fields = [ 44 | 'slug', 45 | 'name', 46 | 'parameters', 47 | 'description', 48 | ] 49 | 50 | 51 | class GrantAdmin(admin.ModelAdmin): 52 | 53 | model = Grant 54 | 55 | def assignment__dumps(self, instance): 56 | return simplejson.dumps(instance.assignment) 57 | assignment__dumps.short_description = 'Assignment' 58 | 59 | list_display = [ 60 | 'from_role', 61 | 'to_role', 62 | 'assignment__dumps', 63 | ] 64 | 65 | search_fields = [ 66 | 'from_role__name', 67 | 'from_role__description', 68 | 'to_role__name', 69 | 'to_role__description', 70 | ] 71 | 72 | def get_queryset(self, request): 73 | return Grant.objects.select_related('to_role', 'from_role') 74 | 75 | 76 | admin.site.register(Role, RoleAdmin) 77 | admin.site.register(Grant, GrantAdmin) 78 | -------------------------------------------------------------------------------- /django_prbac/arbitrary.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from random import choice 3 | 4 | from django.contrib.auth.models import User 5 | 6 | from django_prbac.models import * 7 | 8 | __all__ = [ 9 | 'role', 10 | 'grant', 11 | 'unique_name', 12 | ] 13 | 14 | 15 | def instantiate(generator_or_value): 16 | """ 17 | Dynamic typing hack to try to call generators if provided, 18 | otherwise return the value directly if not callable. This will 19 | break badly if used for values that can be callable. 20 | """ 21 | 22 | if callable(generator_or_value): 23 | return generator_or_value() 24 | else: 25 | return generator_or_value 26 | 27 | 28 | def arbitrary_slug(): 29 | return choice(['foo', 'bar', 'baz', 'zizzle', 'zazzle']) 30 | 31 | 32 | def arbitrary_unique_slug(prefix=None, suffix=None): 33 | prefix = instantiate(prefix or '') 34 | suffix = instantiate(suffix or '') 35 | return prefix + arbitrary_slug() + uuid.uuid4().hex + suffix 36 | 37 | 38 | def arbitrary_user(username=None, password=None, email=None, save=True, **kwargs): 39 | username = instantiate(username or arbitrary_unique_slug)[:74] 40 | password = instantiate(password or arbitrary_unique_slug)[:74] 41 | email = instantiate(email) if email is not None else ('%s@%s.com' % (arbitrary_unique_slug(), arbitrary_unique_slug()))[:74] 42 | 43 | user = User(username=username, 44 | password=password, 45 | email=email, 46 | **kwargs) 47 | 48 | if save: 49 | user.save() 50 | 51 | return user 52 | 53 | 54 | def arbitrary_role(slug=None, name=None, save=True, **kwargs): 55 | slug = instantiate(slug or arbitrary_unique_slug) 56 | name = instantiate(name or arbitrary_slug) 57 | 58 | role = Role( 59 | slug=slug, 60 | name=name, 61 | **kwargs 62 | ) 63 | 64 | if save: 65 | role.save() 66 | 67 | return role 68 | 69 | 70 | def arbitrary_grant(from_role=None, to_role=None, save=True, **kwargs): 71 | from_role = instantiate(from_role if from_role is not None else arbitrary_role) 72 | to_role = instantiate(to_role if to_role is not None else arbitrary_role) 73 | 74 | grant = Grant( 75 | from_role=from_role, 76 | to_role=to_role, 77 | **kwargs 78 | ) 79 | 80 | if save: 81 | grant.save() 82 | 83 | return grant 84 | 85 | 86 | def arbitrary_user_role(user=None, role=None, save=True, **kwargs): 87 | user = instantiate(user or arbitrary_user) 88 | role = instantiate(role or arbitrary_role) 89 | 90 | user_role = UserRole(user=user, 91 | role=role, 92 | **kwargs) 93 | 94 | if save: 95 | user_role.save() 96 | 97 | return user_role 98 | 99 | 100 | role = arbitrary_role 101 | grant = arbitrary_grant 102 | unique_slug = arbitrary_unique_slug 103 | user = arbitrary_user 104 | user_role = arbitrary_user_role 105 | -------------------------------------------------------------------------------- /django_prbac/csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from io import StringIO 3 | 4 | 5 | def parse_line(value, quotechar=None, **kwargs): 6 | """ 7 | A simple wrapper to parse a single CSV value 8 | """ 9 | quotechar = quotechar or '"' 10 | return next(csv.reader([value], quotechar=quotechar, **kwargs), None) 11 | 12 | 13 | def line_to_string(value, **kwargs): 14 | """ 15 | A simple wrapper to write one CSV line 16 | """ 17 | fh = StringIO() 18 | csv.writer(fh, **kwargs).writerow(value) 19 | return fh.getvalue() 20 | -------------------------------------------------------------------------------- /django_prbac/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.http import Http404 4 | from django_prbac.exceptions import PermissionDenied 5 | from django_prbac.utils import has_privilege 6 | 7 | 8 | def requires_privilege(slug, **assignment): 9 | def decorate(fn): 10 | """ 11 | Returns a function equivalent to `fn` but that requires 12 | a role with slug `slug` to be reachable from `request.role` 13 | or `request.user.prbac_role` 14 | with the parameters specified in `assignment` 15 | (in a parameterized fashion) 16 | """ 17 | @wraps(fn) 18 | def wrapped(request, *args, **kwargs): 19 | if not has_privilege(request, slug, **assignment): 20 | raise PermissionDenied() 21 | return fn(request, *args, **kwargs) 22 | 23 | return wrapped 24 | 25 | return decorate 26 | 27 | 28 | def requires_privilege_raise404(slug, **assignment): 29 | """ 30 | A version of the requires_privilege decorator which raises an Http404 31 | if PermissionDenied is raised. 32 | """ 33 | def decorate(fn): 34 | @wraps(fn) 35 | def wrapped(request, *args, **kwargs): 36 | if not has_privilege(request, slug, **assignment): 37 | raise Http404() 38 | return fn(request, *args, **kwargs) 39 | return wrapped 40 | return decorate 41 | -------------------------------------------------------------------------------- /django_prbac/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class PermissionDenied(Exception): 3 | pass 4 | -------------------------------------------------------------------------------- /django_prbac/fields.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | import django_prbac.csv 4 | from django_prbac.forms import StringListFormField 5 | 6 | 7 | class StringListField(models.TextField): 8 | """ 9 | A Django field for lists of strings 10 | """ 11 | 12 | def is_string_list(self, value): 13 | return isinstance(value, list) and all([isinstance(v, str) for v in value]) 14 | 15 | def to_python(self, value): 16 | """ 17 | Best-effort conversion of "any value" to a string list. 18 | 19 | It does not try that hard, because curious values probably indicate 20 | a mistake and we should fail early. 21 | """ 22 | 23 | # Already the appropriate python type 24 | if self.is_string_list(value): 25 | return value 26 | elif isinstance(value, str): 27 | return django_prbac.csv.parse_line(value) 28 | else: 29 | raise ValueError('Invalid value for StringListField: %r is neither the correct type nor deserializable' % value) 30 | 31 | def get_prep_value(self, value): 32 | """ 33 | Converts the value, which must be a string list, to a comma-separated string, 34 | quoted appropriately. This format is private to the field type so it is not 35 | exposed for customization or any such thing. 36 | """ 37 | 38 | if not self.is_string_list(value): 39 | raise ValueError('Invalid value for StringListField: %r' % value) 40 | else: 41 | return django_prbac.csv.line_to_string(value, lineterminator='') 42 | 43 | def value_to_string(self, obj): 44 | value = self.value_from_object(obj) 45 | return self.get_prep_value(value) 46 | 47 | def formfield(self, **kwargs): 48 | """ 49 | The default form field is a StringListFormField. 50 | """ 51 | 52 | defaults = {'form_class': StringListFormField} 53 | defaults.update(kwargs) 54 | return super(StringListField, self).formfield(**defaults) 55 | 56 | def from_db_value(self, value, expression, connection, *args, **kwargs): 57 | return self.to_python(value) 58 | 59 | 60 | class StringSetField(StringListField): 61 | """ 62 | A Django field for set of strings. 63 | """ 64 | 65 | # TODO thought: If Python had polymorphism this ought be "Serialize a => Field (List a)" 66 | 67 | def is_string_set(self, value): 68 | return isinstance(value, set) and all([isinstance(v, str) for v in value]) 69 | 70 | def to_python(self, value): 71 | """ 72 | Best-effort conversion of "any value" to a string set. Mostly strict, 73 | but a bit lenient to allow lists to be passed in by form fields. 74 | """ 75 | 76 | # Already the appropriate python type 77 | if self.is_string_set(value): 78 | return value 79 | 80 | # If it is a string list, we will turn it into a set; this lenience let's us 81 | # re-use the form field easily 82 | if self.is_string_list(value): 83 | return set(value) 84 | 85 | # First let StringListField do whatever it needs to do; this will now be a string list 86 | try: 87 | value = super(StringSetField, self).to_python(value) 88 | except ValueError: 89 | raise ValueError('Invalid value for StringSetField: %r' % value) 90 | 91 | return set(value) 92 | 93 | def value_to_string(self, obj): 94 | value = self.value_from_object(obj) 95 | return self.get_prep_value(value) 96 | 97 | def get_prep_value(self, value): 98 | if not self.is_string_set(value): 99 | raise ValueError('Invalid value %r for StringSetField' % value) 100 | else: 101 | return super(StringSetField, self).get_prep_value(sorted(value)) 102 | 103 | def from_db_value(self, value, expression, connection, *args, **kwargs): 104 | return self.to_python(value) 105 | -------------------------------------------------------------------------------- /django_prbac/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ValidationError, CharField, TextInput 2 | 3 | import django_prbac.csv 4 | 5 | 6 | class StringListInput(TextInput): 7 | def render(self, name, value, attrs=None, renderer=None): 8 | if isinstance(value, str): 9 | return super(StringListInput, self).render(name, value) 10 | else: 11 | rendered_value = django_prbac.csv.line_to_string(list(value)) 12 | return super(StringListInput, self).render(name, rendered_value) 13 | 14 | 15 | class StringSetInput(TextInput): 16 | def render(self, name, value, attrs=None, renderer=None): 17 | if isinstance(value, str): 18 | return super(StringSetInput, self).render(name, value) 19 | else: 20 | rendered_value = django_prbac.csv.line_to_string(sorted(list(value))) 21 | return super(StringSetInput, self).render(name, rendered_value) 22 | 23 | 24 | class StringListFormField(CharField): 25 | """ 26 | A Django form field for lists of strings separated by commas, quotes optional 27 | """ 28 | def __init__(self, quotechar=None, skipinitialspace=None, *args, **kwargs): 29 | self.quotechar = (quotechar or '"') 30 | self.skipinitialspace = True if skipinitialspace is None else skipinitialspace 31 | defaults = {'widget': StringListInput} 32 | defaults.update(kwargs) 33 | super(StringListFormField, self).__init__(*args, **defaults) 34 | 35 | def is_string_list(self, value): 36 | return isinstance(value, list) and all([isinstance(v, str) for v in value]) 37 | 38 | def clean(self, value): 39 | if self.is_string_list(value): 40 | return value 41 | 42 | elif not isinstance(value, str): 43 | raise ValidationError('%r cannot be converted to a string list' % value) 44 | 45 | else: 46 | try: 47 | return django_prbac.csv.parse_line( 48 | value, 49 | skipinitialspace=self.skipinitialspace, 50 | quotechar=self.quotechar, 51 | ) 52 | 53 | except ValueError: 54 | raise ValidationError('%r cannot be converted to a string list' % value) 55 | 56 | -------------------------------------------------------------------------------- /django_prbac/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import models, migrations 6 | import django_prbac.fields 7 | from django.conf import settings 8 | import jsonfield.fields 9 | import django_prbac.models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Grant', 21 | fields=[ 22 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 23 | ('assignment', jsonfield.fields.JSONField(default=dict, help_text='Assignment from parameters (strings) to values (any JSON-compatible value)', blank=True)), 24 | ], 25 | bases=(django_prbac.models.ValidatingModel, models.Model), 26 | ), 27 | migrations.CreateModel( 28 | name='Role', 29 | fields=[ 30 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 31 | ('slug', models.CharField(help_text='The formal slug for this role, which should be unique', unique=True, max_length=256)), 32 | ('name', models.CharField(help_text='The friendly name for this role to present to users; this need not be unique.', max_length=256)), 33 | ('description', models.TextField(default='', help_text='A long-form description of the intended semantics of this role.', blank=True)), 34 | ('parameters', django_prbac.fields.StringSetField(default=set, help_text='A set of strings which are the parameters for this role. Entered as a JSON list.', blank=True)), 35 | ], 36 | bases=(django_prbac.models.ValidatingModel, models.Model), 37 | ), 38 | migrations.CreateModel( 39 | name='UserRole', 40 | fields=[ 41 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 42 | ('role', models.OneToOneField(related_name='user_role', to='django_prbac.Role', on_delete=models.CASCADE)), 43 | ('user', models.OneToOneField(related_name='prbac_role', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 44 | ], 45 | bases=(django_prbac.models.ValidatingModel, models.Model), 46 | ), 47 | migrations.AddField( 48 | model_name='grant', 49 | name='from_role', 50 | field=models.ForeignKey(related_name='memberships_granted', to='django_prbac.Role', help_text='The sub-role begin granted membership or permission', on_delete=models.CASCADE), 51 | ), 52 | migrations.AddField( 53 | model_name='grant', 54 | name='to_role', 55 | field=models.ForeignKey(related_name='members', to='django_prbac.Role', help_text='The super-role or permission being given', on_delete=models.CASCADE), 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /django_prbac/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimagi/django-prbac/9ee9354037f73120e45a91d7afdf91a46f6a6210/django_prbac/migrations/__init__.py -------------------------------------------------------------------------------- /django_prbac/mock_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Settings just for running the django-prbac tests or checking out 3 | the admin site. 4 | """ 5 | 6 | SECRET_KEY = 'Not a secret key at all, actually' 7 | 8 | DEBUG = True 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': 'django-prbac.db', 14 | } 15 | } 16 | 17 | INSTALLED_APPS = [ 18 | # Django apps necessary to run the admin site 19 | 'django.contrib.auth', 20 | 'django.contrib.admin', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.sessions', 23 | 'django.contrib.staticfiles', 24 | 'django.contrib.messages', 25 | 26 | # And this app 27 | 'django_prbac', 28 | ] 29 | 30 | STATIC_URL = '/static/' 31 | 32 | ROOT_URLCONF = 'django_prbac.urls' 33 | 34 | MIDDLEWARE = [ 35 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 36 | 'django.contrib.messages.middleware.MessageMiddleware', 37 | 'django.contrib.sessions.middleware.SessionMiddleware', 38 | ] 39 | 40 | TEMPLATES = [ 41 | { 42 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 43 | 'OPTIONS': { 44 | 'context_processors': [ 45 | 'django.contrib.auth.context_processors.auth', 46 | 'django.contrib.messages.context_processors.messages', 47 | ], 48 | } 49 | }, 50 | ] 51 | -------------------------------------------------------------------------------- /django_prbac/models.py: -------------------------------------------------------------------------------- 1 | import time 2 | import weakref 3 | 4 | from django import VERSION 5 | from django.db import models 6 | from django.conf import settings 7 | if VERSION[0] < 3: 8 | from django.utils.encoding import python_2_unicode_compatible 9 | else: 10 | def python_2_unicode_compatible(fn): 11 | return fn 12 | 13 | import jsonfield 14 | 15 | from django_prbac.fields import StringSetField 16 | 17 | 18 | __all__ = [ 19 | 'Role', 20 | 'Grant', 21 | 'RoleInstance', 22 | 'UserRole', 23 | ] 24 | 25 | 26 | class ValidatingModel(object): 27 | def save(self, force_insert=False, force_update=False, **kwargs): 28 | if not (force_insert or force_update): 29 | self.full_clean() # Will raise ValidationError if needed 30 | super(ValidatingModel, self).save(force_insert, force_update, **kwargs) 31 | 32 | 33 | @python_2_unicode_compatible 34 | class Role(ValidatingModel, models.Model): 35 | """ 36 | A PRBAC role, aka a Role parameterized by a set of named variables. Roles 37 | also model privileges: They differ only in that privileges only refer 38 | to real-world consequences when all parameters are instantiated. 39 | """ 40 | 41 | PRIVILEGES_BY_SLUG = "DJANGO_PRBAC_PRIVELEGES" 42 | ROLES_BY_ID = "DJANGO_PRBAC_ROLES" 43 | _default_instance = lambda s:None 44 | 45 | # Databaes fields 46 | # --------------- 47 | 48 | slug = models.CharField( 49 | max_length=256, 50 | help_text='The formal slug for this role, which should be unique', 51 | unique=True, 52 | ) 53 | 54 | name = models.CharField( 55 | max_length=256, 56 | help_text='The friendly name for this role to present to users; this need not be unique.', 57 | ) 58 | 59 | description = models.TextField( 60 | help_text='A long-form description of the intended semantics of this role.', 61 | blank=True, 62 | default='', 63 | ) 64 | 65 | parameters = StringSetField( 66 | help_text='A set of strings which are the parameters for this role. Entered as a JSON list.', 67 | blank=True, 68 | default=set, 69 | ) 70 | 71 | class Meta: 72 | app_label = 'django_prbac' 73 | 74 | # Methods 75 | # ------- 76 | 77 | @classmethod 78 | def get_cache(cls): 79 | try: 80 | cache = cls.cache 81 | except AttributeError: 82 | timeout = getattr(settings, 'DJANGO_PRBAC_CACHE_TIMEOUT', 60) 83 | cache = cls.cache = DictCache(timeout) 84 | return cache 85 | 86 | @classmethod 87 | def update_cache(cls): 88 | roles = cls.objects.prefetch_related('memberships_granted').all() 89 | roles = {role.id: role for role in roles} 90 | for role in roles.values(): 91 | role._granted_privileges = privileges = [] 92 | # Prevent extra queries by manually linking grants and roles 93 | # because Django 1.6 isn't smart enough to do this for us 94 | for membership in role.memberships_granted.all(): 95 | membership.to_role = roles[membership.to_role_id] 96 | membership.from_role = roles[membership.from_role_id] 97 | privileges.append(membership.instantiated_to_role({})) 98 | cache = cls.get_cache() 99 | cache.set(cls.ROLES_BY_ID, roles) 100 | cache.set(cls.PRIVILEGES_BY_SLUG, 101 | {role.slug: role.instantiate({}) for role in roles.values()}) 102 | 103 | @classmethod 104 | def get_privilege(cls, slug, assignment=None): 105 | """ 106 | Optimized lookup of privilege by slug 107 | 108 | This optimization is specifically geared toward cases where 109 | `assignments` is empty. 110 | """ 111 | cache = cls.get_cache() 112 | if cache.disabled: 113 | roles = Role.objects.filter(slug=slug) 114 | if roles: 115 | return roles[0].instantiate(assignment or {}) 116 | return None 117 | privileges = cache.get(cls.PRIVILEGES_BY_SLUG) 118 | if privileges is None: 119 | cls.update_cache() 120 | privileges = cache.get(cls.PRIVILEGES_BY_SLUG) 121 | privilege = privileges.get(slug) 122 | if privilege is None: 123 | return None 124 | if assignment: 125 | return privilege.role.instantiate(assignment) 126 | return privilege 127 | 128 | def get_cached_role(self): 129 | """ 130 | Optimized lookup of role by id 131 | """ 132 | cache = self.get_cache() 133 | if cache.disabled: 134 | return self 135 | roles = cache.get(self.ROLES_BY_ID) 136 | if roles is None or self.id not in roles: 137 | self.update_cache() 138 | roles = cache.get(self.ROLES_BY_ID) 139 | return roles.get(self.id, self) 140 | 141 | def get_privileges(self, assignment): 142 | if not assignment: 143 | try: 144 | return self._granted_privileges 145 | except AttributeError: 146 | pass 147 | try: 148 | memberships = self.memberships_granted.all() 149 | except ValueError: 150 | # Django 4 raises ValueError if fk relationship is accessed prior to save 151 | return [] 152 | return [m.instantiated_to_role(assignment) for m in memberships] 153 | 154 | def instantiate(self, assignment): 155 | """ 156 | An instantiation of this role with some parameters fixed via the provided assignments. 157 | """ 158 | if assignment: 159 | filtered_assignment = {key: assignment[key] 160 | for key in self.parameters & set(assignment.keys())} 161 | else: 162 | value = self._default_instance() 163 | if value is not None: 164 | return value 165 | filtered_assignment = assignment 166 | value = RoleInstance(self, filtered_assignment) 167 | if not filtered_assignment: 168 | self._default_instance = weakref.ref(value) 169 | return value 170 | 171 | def has_privilege(self, privilege): 172 | """ 173 | Shortcut for checking privileges easily for roles with no params (aka probably users) 174 | """ 175 | role = self.get_cached_role() 176 | return role.instantiate({}).has_privilege(privilege) 177 | 178 | @property 179 | def assignment(self): 180 | """ 181 | A Role stored in the database always has an empty assignment. 182 | """ 183 | return {} 184 | 185 | def __repr__(self): 186 | return 'Role(%r, parameters=%r)' % (self.slug, self.parameters) 187 | 188 | def __str__(self): 189 | return '%s (%s)' % (self.name, self.slug) 190 | 191 | 192 | class Grant(ValidatingModel, models.Model): 193 | """ 194 | A parameterized membership between a sub-role and super-role. 195 | The parameters applied to the super-role are all those. 196 | """ 197 | 198 | # Database Fields 199 | # --------------- 200 | 201 | from_role = models.ForeignKey( 202 | 'Role', 203 | help_text='The sub-role begin granted membership or permission', 204 | related_name='memberships_granted', 205 | on_delete=models.CASCADE, 206 | ) 207 | 208 | to_role = models.ForeignKey( 209 | 'Role', 210 | help_text='The super-role or permission being given', 211 | related_name='members', 212 | on_delete=models.CASCADE, 213 | ) 214 | 215 | assignment = jsonfield.JSONField( 216 | help_text='Assignment from parameters (strings) to values (any JSON-compatible value)', 217 | blank=True, 218 | default=dict, 219 | ) 220 | 221 | class Meta: 222 | app_label = 'django_prbac' 223 | 224 | # Methods 225 | # ------- 226 | 227 | def instantiated_to_role(self, assignment): 228 | """ 229 | Returns the super-role instantiated with the parameters of the membership 230 | composed with the `parameters` passed in. 231 | """ 232 | composed_assignment = {} 233 | if assignment: 234 | for key in self.to_role.parameters & set(assignment.keys()): 235 | composed_assignment[key] = assignment[key] 236 | if self.assignment: 237 | composed_assignment.update(self.assignment) 238 | return self.to_role.instantiate(composed_assignment) 239 | 240 | def __repr__(self): 241 | return 'Grant(from_role=%r, to_role=%r, assignment=%r)' % (self.from_role, self.to_role, self.assignment) 242 | 243 | 244 | class UserRole(ValidatingModel, models.Model): 245 | """ 246 | A link between a django.contrib.auth.models.User and 247 | a django_prbac.models.Role. They are kept to 248 | one-to-one fields to make their use extremely simple: 249 | 250 | request.user.prbac_role.has_privilege(...) 251 | """ 252 | 253 | user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='prbac_role', on_delete=models.CASCADE) 254 | role = models.OneToOneField(Role, related_name='user_role', on_delete=models.CASCADE) 255 | 256 | class Meta: 257 | app_label = 'django_prbac' 258 | 259 | def has_privilege(self, privilege): 260 | return self.role.has_privilege(privilege) 261 | 262 | def __eq__(self, other): 263 | return self.user == other.user and self.role == other.role 264 | 265 | def __repr__(self): 266 | return 'UserRole(user=%r, role=%r)' % (self.user, self.role) 267 | 268 | 269 | class RoleInstance(object): 270 | """ 271 | A parameterized role along with some parameters that are fixed. Note that this is 272 | not a model but only a transient Python object. 273 | """ 274 | 275 | def __init__(self, role, assignment): 276 | self.role = role 277 | self.assignment = assignment 278 | self.slug = role.slug 279 | self.name = role.name 280 | self.parameters = role.parameters - set(assignment.keys()) 281 | 282 | def instantiate(self, assignment): 283 | """ 284 | This role further instantiated with the additional assignment. 285 | Note that any parameters that are already fixed are not actually 286 | available for being assigned, so will _not_ change. 287 | """ 288 | composed_assignment = {} 289 | if assignment: 290 | for key in self.parameters & set(assignment.keys()): 291 | composed_assignment[key] = assignment[key] 292 | if self.assignment: 293 | composed_assignment.update(self.assignment) 294 | # this seems like a bug (wrong arguments). is this method ever called? 295 | return RoleInstance(composed_assignment) 296 | 297 | def has_privilege(self, privilege): 298 | """ 299 | True if this instantiated role is allowed the privilege passed in, 300 | (which is itself an RoleInstance) 301 | """ 302 | 303 | if self == privilege: 304 | return True 305 | 306 | return any(p.has_privilege(privilege) 307 | for p in self.role.get_privileges(self.assignment)) 308 | 309 | def __eq__(self, other): 310 | return self.slug == other.slug and self.assignment == other.assignment 311 | 312 | def __repr__(self): 313 | return 'RoleInstance(%r, parameters=%r, assignment=%r)' % (self.slug, self.parameters, self.assignment) 314 | 315 | 316 | class DictCache(object): 317 | """A simple in-memory dict cache 318 | 319 | :param timeout: Number of seconds until an item in the cache expires. 320 | """ 321 | 322 | def __init__(self, timeout=60): 323 | self.timeout = timeout 324 | self.data = {} 325 | 326 | @property 327 | def disabled(self): 328 | return self.timeout == 0 329 | 330 | def get(self, key, default=None): 331 | now = time.time() 332 | value, expires = self.data.get(key, (default, now)) 333 | if now > expires: 334 | self.data.pop(key) 335 | return default 336 | return value 337 | 338 | def set(self, key, value): 339 | self.data[key] = (value, time.time() + self.timeout) 340 | 341 | def clear(self): 342 | self.data.clear() 343 | -------------------------------------------------------------------------------- /django_prbac/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimagi/django-prbac/9ee9354037f73120e45a91d7afdf91a46f6a6210/django_prbac/tests/__init__.py -------------------------------------------------------------------------------- /django_prbac/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.http import HttpRequest 3 | 4 | from django_prbac.decorators import requires_privilege 5 | from django_prbac.exceptions import PermissionDenied 6 | from django_prbac.models import Role 7 | from django_prbac import arbitrary 8 | 9 | 10 | class TestDecorators(TestCase): 11 | 12 | def setUp(self): 13 | Role.get_cache().clear() 14 | self.zazzle_privilege = arbitrary.role(slug=arbitrary.unique_slug('zazzle'), parameters=set(['domain'])) 15 | 16 | def test_requires_privilege_no_current_role(self): 17 | """ 18 | When a privilege is required but there is no role attached 19 | to the current request, permission is denied. No crashing. 20 | """ 21 | @requires_privilege(self.zazzle_privilege.slug, domain='zizzle') 22 | def view(request, *args, **kwargs): 23 | pass 24 | 25 | request = HttpRequest() 26 | with self.assertRaises(PermissionDenied): 27 | view(request) 28 | 29 | def test_requires_privilege_no_such(self): 30 | """ 31 | When a required privilege is not even defined in the database, 32 | permission is denied; no crashing. 33 | """ 34 | @requires_privilege('bomboozle', domain='zizzle') 35 | def view(request, *args, **kwargs): 36 | pass 37 | 38 | requestor_role = arbitrary.role() 39 | request = HttpRequest() 40 | request.role = requestor_role 41 | with self.assertRaises(PermissionDenied): 42 | view(request) 43 | 44 | def test_requires_privilege_denied(self): 45 | """ 46 | When a privilege exists but the current 47 | role does not have access to it, permission 48 | is denied 49 | """ 50 | 51 | @requires_privilege(self.zazzle_privilege.slug, domain='zizzle') 52 | def view(request, *args, **kwargs): 53 | pass 54 | 55 | requestor_role = arbitrary.role() 56 | 57 | request = HttpRequest() 58 | request.role = requestor_role.instantiate({}) 59 | with self.assertRaises(PermissionDenied): 60 | view(request) 61 | 62 | def test_requires_privilege_wrong_param(self): 63 | 64 | @requires_privilege(self.zazzle_privilege.slug, domain='zizzle') 65 | def view(request, *args, **kwargs): 66 | pass 67 | 68 | requestor_role = arbitrary.role() 69 | arbitrary.grant(from_role=requestor_role, to_role=self.zazzle_privilege, assignment=dict(domain='whapwhap')) 70 | 71 | request = HttpRequest() 72 | request.role = requestor_role.instantiate({}) 73 | with self.assertRaises(PermissionDenied): 74 | view(request) 75 | 76 | def test_requires_privilege_ok(self): 77 | 78 | @requires_privilege(self.zazzle_privilege.slug, domain='zizzle') 79 | def view(request, *args, **kwargs): 80 | pass 81 | 82 | requestor_role = arbitrary.role() 83 | arbitrary.grant(from_role=requestor_role, to_role=self.zazzle_privilege, assignment=dict(domain='zizzle')) 84 | 85 | request = HttpRequest() 86 | request.role = requestor_role.instantiate({}) 87 | view(request) 88 | 89 | def test_requires_privilege_role_on_user_ok(self): 90 | """ 91 | Verify that privilege is recognized when the request user has the prbac_role, but request.role is not set. 92 | """ 93 | 94 | @requires_privilege(self.zazzle_privilege.slug, domain='zizzle') 95 | def view(request, *args, **kwargs): 96 | pass 97 | 98 | user = arbitrary.user() 99 | requestor_role = arbitrary.role() 100 | arbitrary.grant(from_role=requestor_role, to_role=self.zazzle_privilege, assignment=dict(domain='zizzle')) 101 | arbitrary.user_role(user=user, role=requestor_role) 102 | 103 | request = HttpRequest() 104 | request.user = user 105 | view(request) 106 | -------------------------------------------------------------------------------- /django_prbac/tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_prbac.fields import StringListField, StringSetField 4 | 5 | 6 | class TestStringListField(TestCase): 7 | """ 8 | Test suite for django_prbac.fields.StringListField 9 | """ 10 | 11 | def test_is_string_list(self): 12 | field = StringListField('testing') 13 | 14 | self.assertTrue(field.is_string_list([])) 15 | self.assertTrue(field.is_string_list(["hello", "goodbye"])) 16 | 17 | self.assertFalse(field.is_string_list("boo")) 18 | self.assertFalse(field.is_string_list(3)) 19 | self.assertFalse(field.is_string_list('"A","B"')) 20 | 21 | def test_to_python_convert(self): 22 | field = StringListField('testing') 23 | self.assertEqual(field.to_python(''), []) 24 | self.assertEqual(field.to_python('"A","B","C"'), ['A', 'B', 'C']) 25 | 26 | def test_to_python_already_done(self): 27 | field = StringListField('testing') 28 | self.assertEqual(field.to_python([]), []) 29 | self.assertEqual(field.to_python(["A", "B", "C"]), ['A', 'B', 'C']) 30 | 31 | with self.assertRaises(ValueError): 32 | field.to_python(4) 33 | 34 | with self.assertRaises(ValueError): 35 | field.to_python([1, 2, 3]) 36 | 37 | with self.assertRaises(ValueError): 38 | field.to_python(None) 39 | 40 | def test_get_prep_value_convert(self): 41 | field = StringListField('testing') 42 | 43 | self.assertEqual(field.get_prep_value(["A", "B", "C"]), 'A,B,C') 44 | self.assertEqual(field.get_prep_value(["A", "B,C", "D"]), 'A,"B,C",D') 45 | 46 | with self.assertRaises(ValueError): 47 | field.get_prep_value(5) 48 | 49 | 50 | class TestStringSetField(TestCase): 51 | """ 52 | Test suite for django_prbac.fields.StringSetField 53 | """ 54 | 55 | def test_is_string_set(self): 56 | field = StringSetField('testing') 57 | 58 | self.assertTrue(field.is_string_set(set([]))) 59 | self.assertTrue(field.is_string_set(set(["hello", "goodbye"]))) 60 | 61 | self.assertFalse(field.is_string_set(["A", "B"])) 62 | self.assertFalse(field.is_string_set("boo")) 63 | self.assertFalse(field.is_string_set(3)) 64 | self.assertFalse(field.is_string_set('["A", "B"]')) 65 | 66 | def test_to_python_convert(self): 67 | field = StringSetField('testing') 68 | 69 | # This that are legitimate to store in the DB 70 | self.assertEqual(field.to_python(''), set()) 71 | self.assertEqual(field.to_python('"A","B","C"'), set(['A', 'B', 'C'])) 72 | 73 | def test_to_python_already_done(self): 74 | field = StringSetField('testing') 75 | self.assertEqual(field.to_python([]), set()) 76 | self.assertEqual(field.to_python(set(["A","B","C"])), set(['A', 'B', 'C'])) 77 | 78 | with self.assertRaises(ValueError): 79 | field.to_python(4) 80 | 81 | with self.assertRaises(ValueError): 82 | field.to_python([1, 2, 3]) 83 | 84 | with self.assertRaises(ValueError): 85 | field.to_python(None) 86 | 87 | def test_get_prep_value_convert(self): 88 | field = StringSetField('testing') 89 | 90 | self.assertEqual(field.get_prep_value(set(["A", "B", "C"])), 'A,B,C') 91 | self.assertEqual(field.get_prep_value(set(["C", "B", "A"])), 'A,B,C') 92 | 93 | with self.assertRaises(ValueError): 94 | field.get_prep_value(5) 95 | -------------------------------------------------------------------------------- /django_prbac/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_prbac.forms import StringListFormField 4 | 5 | 6 | class TestStringListFormField(TestCase): 7 | """ 8 | Test suite for django_prbac.fields.StringListField 9 | """ 10 | 11 | def test_clean(self): 12 | field = StringListFormField(required=False, skipinitialspace=False) 13 | 14 | self.assertEqual(field.clean('hello, goodbye'), ['hello', ' goodbye']) 15 | self.assertEqual(field.clean('hello,goodbye'), ['hello', 'goodbye']) 16 | self.assertEqual(field.clean('"hello", goodbye'), ['hello', ' goodbye']) 17 | self.assertEqual(field.clean('"hello"," oh no "'), ['hello', ' oh no ']) 18 | self.assertEqual(field.clean('"hello","one,two"'), ['hello', 'one,two']) 19 | 20 | def test_quotechar(self): 21 | field = StringListFormField(required=False, quotechar='|') 22 | 23 | self.assertEqual(field.clean('hello, goodbye'), ['hello', 'goodbye']) 24 | self.assertEqual(field.clean('hello,goodbye'), ['hello', 'goodbye']) 25 | self.assertEqual(field.clean('hello, goodbye'), ['hello', 'goodbye']) 26 | self.assertEqual(field.clean('"hello", goodbye'), ['"hello"', 'goodbye']) 27 | self.assertEqual(field.clean('"hello","oh, no"'), ['"hello"', '"oh', 'no"']) 28 | self.assertEqual(field.clean('hello,|oh, no|'), ['hello', 'oh, no']) 29 | -------------------------------------------------------------------------------- /django_prbac/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase # https://code.djangoproject.com/ticket/20913 2 | 3 | from django_prbac.models import Role 4 | from django_prbac import arbitrary 5 | 6 | 7 | class TestRole(TestCase): 8 | 9 | def setUp(self): 10 | Role.get_cache().clear() 11 | 12 | def test_has_permission_immediate_no_params(self): 13 | subrole = arbitrary.role() 14 | superrole1 = arbitrary.role() 15 | superrole2 = arbitrary.role() 16 | arbitrary.grant(to_role=superrole1, from_role=subrole) 17 | 18 | # A few ways of saying the same thing 19 | self.assertTrue(subrole.instantiate({}).has_privilege(superrole1.instantiate({}))) 20 | self.assertTrue(subrole.has_privilege(superrole1.instantiate({}))) 21 | self.assertTrue(subrole.instantiate({}).has_privilege(superrole1)) 22 | self.assertTrue(subrole.has_privilege(superrole1)) 23 | 24 | self.assertFalse(subrole.instantiate({}).has_privilege(superrole2.instantiate({}))) 25 | self.assertFalse(subrole.has_privilege(superrole2.instantiate({}))) 26 | self.assertFalse(subrole.instantiate({}).has_privilege(superrole2)) 27 | self.assertFalse(subrole.has_privilege(superrole2)) 28 | 29 | def test_has_permission_transitive_no_params(self): 30 | subrole = arbitrary.role() 31 | midrole = arbitrary.role() 32 | superrole1 = arbitrary.role() 33 | superrole2 = arbitrary.role() 34 | arbitrary.grant(to_role=midrole, from_role=subrole) 35 | arbitrary.grant(to_role=superrole1, from_role=midrole) 36 | 37 | # A few ways of saying the same thing 38 | self.assertTrue(subrole.instantiate({}).has_privilege(superrole1.instantiate({}))) 39 | self.assertTrue(subrole.has_privilege(superrole1.instantiate({}))) 40 | self.assertTrue(subrole.instantiate({}).has_privilege(superrole1)) 41 | self.assertTrue(subrole.has_privilege(superrole1)) 42 | 43 | self.assertFalse(subrole.instantiate({}).has_privilege(superrole2.instantiate({}))) 44 | self.assertFalse(subrole.has_privilege(superrole2.instantiate({}))) 45 | self.assertFalse(subrole.instantiate({}).has_privilege(superrole2)) 46 | self.assertFalse(subrole.has_privilege(superrole2)) 47 | 48 | def test_has_permission_far_transitive_no_params(self): 49 | subrole = arbitrary.role() 50 | superrole1 = arbitrary.role() 51 | superrole2 = arbitrary.role() 52 | 53 | midroles = [arbitrary.role() for __ in range(0, 10)] 54 | 55 | arbitrary.grant(subrole, midroles[0]) 56 | arbitrary.grant(midroles[-1], superrole1) 57 | 58 | # Link up all roles in the list that are adjacent 59 | for midsubrole, midsuperrole in zip(midroles[:-1], midroles[1:]): 60 | arbitrary.grant(from_role=midsubrole, to_role=midsuperrole) 61 | 62 | self.assertTrue(subrole.instantiate({}).has_privilege(superrole1.instantiate({}))) 63 | self.assertFalse(subrole.instantiate({}).has_privilege(superrole2.instantiate({}))) 64 | 65 | def test_has_permission_immediate_params(self): 66 | subrole = arbitrary.role() 67 | superrole1 = arbitrary.role(parameters=set(['one'])) 68 | arbitrary.grant(to_role=superrole1, from_role=subrole, assignment=dict(one='foo')) 69 | 70 | self.assertTrue(subrole.instantiate({}).has_privilege(superrole1.instantiate(dict(one='foo')))) 71 | self.assertFalse(subrole.instantiate({}).has_privilege(superrole1.instantiate(dict(one='baz')))) 72 | 73 | def test_unsaved_role_does_not_have_permission(self): 74 | role1 = Role() 75 | role2 = arbitrary.role() 76 | self.assertFalse(role1.has_privilege(role2)) 77 | self.assertFalse(role2.has_privilege(role1)) 78 | 79 | 80 | class TestGrant(TestCase): 81 | 82 | def test_instantiated_to_role_smoke_test(self): 83 | """ 84 | Basic smoke test: 85 | 1. grant.instantiated_role({})[param] == grant.assignment[param] if param is free for the role 86 | 2. grant.instantiated_role({})[param] does not exist if param is not free for the role 87 | """ 88 | 89 | parameters = ['one'] 90 | 91 | superrole = arbitrary.role(parameters=parameters) 92 | grant = arbitrary.grant(to_role=superrole, assignment={'one': 'hello'}) 93 | self.assertEqual(grant.instantiated_to_role({}).assignment, {'one': 'hello'}) 94 | 95 | grant = arbitrary.grant(to_role=superrole, assignment={'two': 'goodbye'}) 96 | self.assertEqual(grant.instantiated_to_role({}).assignment, {}) 97 | 98 | 99 | class TestUserRole(TestCase): 100 | 101 | def setUp(self): 102 | Role.get_cache().clear() 103 | 104 | def test_user_role_integration(self): 105 | """ 106 | Basic smoke test of integration of PRBAC with django.contrib.auth 107 | """ 108 | user = arbitrary.user() 109 | role = arbitrary.role() 110 | priv = arbitrary.role() 111 | arbitrary.grant(from_role=role, to_role=priv) 112 | user_role = arbitrary.user_role(user=user, role=role) 113 | 114 | self.assertEqual(user.prbac_role, user_role) 115 | self.assertTrue(user.prbac_role.has_privilege(role)) 116 | self.assertTrue(user.prbac_role.has_privilege(priv)) 117 | -------------------------------------------------------------------------------- /django_prbac/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.contrib import admin 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | re_path(r'^admin/', admin.site.urls), 8 | ] 9 | -------------------------------------------------------------------------------- /django_prbac/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django_prbac.exceptions import PermissionDenied 4 | from django_prbac.models import Role, UserRole 5 | 6 | 7 | def has_privilege(request, slug, **assignment): 8 | """ 9 | Returns true if the request has the privilege specified by slug, 10 | otherwise false 11 | """ 12 | privilege = Role.get_privilege(slug, assignment) 13 | if privilege is None: 14 | return False 15 | 16 | if hasattr(request, 'role'): 17 | if request.role.has_privilege(privilege): 18 | return True 19 | 20 | if hasattr(request, 'user') and hasattr(request.user, 'prbac_role'): 21 | try: 22 | request.user.prbac_role 23 | except UserRole.DoesNotExist: 24 | return False 25 | return request.user.prbac_role.has_privilege(privilege) 26 | 27 | return False 28 | 29 | 30 | def ensure_request_has_privilege(request, slug, **assignment): 31 | """ 32 | DEPRECATED 33 | """ 34 | warnings.warn( 35 | '`ensure_request_has_privilege` is deprecated. You likely want ' 36 | '`has_permission` or one of the `requires_privilege` decorators', 37 | DeprecationWarning 38 | ) 39 | if not has_privilege(request, slug, **assignment): 40 | raise PermissionDenied() 41 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-prbac.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-prbac.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-prbac" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-prbac" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/apidoc/django_prbac.migrations.rst: -------------------------------------------------------------------------------- 1 | migrations Package 2 | ================== 3 | 4 | :mod:`0001_initial` Module 5 | -------------------------- 6 | 7 | .. automodule:: django_prbac.migrations.0001_initial 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | -------------------------------------------------------------------------------- /docs/apidoc/django_prbac.rst: -------------------------------------------------------------------------------- 1 | django_prbac Package 2 | ==================== 3 | 4 | :mod:`django_prbac` Package 5 | --------------------------- 6 | 7 | .. automodule:: django_prbac.__init__ 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | :mod:`admin` Module 13 | ------------------- 14 | 15 | .. automodule:: django_prbac.admin 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | :mod:`arbitrary` Module 21 | ----------------------- 22 | 23 | .. automodule:: django_prbac.arbitrary 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | :mod:`decorators` Module 29 | ------------------------ 30 | 31 | .. automodule:: django_prbac.decorators 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | :mod:`exceptions` Module 37 | ------------------------ 38 | 39 | .. automodule:: django_prbac.exceptions 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | :mod:`fields` Module 45 | -------------------- 46 | 47 | .. automodule:: django_prbac.fields 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | :mod:`mock_settings` Module 53 | --------------------------- 54 | 55 | .. automodule:: django_prbac.mock_settings 56 | :members: 57 | :undoc-members: 58 | :show-inheritance: 59 | 60 | :mod:`models` Module 61 | -------------------- 62 | 63 | .. automodule:: django_prbac.models 64 | :members: 65 | :undoc-members: 66 | :show-inheritance: 67 | 68 | :mod:`urls` Module 69 | ------------------ 70 | 71 | .. automodule:: django_prbac.urls 72 | :members: 73 | :undoc-members: 74 | :show-inheritance: 75 | 76 | Subpackages 77 | ----------- 78 | 79 | .. toctree:: 80 | 81 | django_prbac.migrations 82 | django_prbac.tests 83 | 84 | -------------------------------------------------------------------------------- /docs/apidoc/django_prbac.tests.rst: -------------------------------------------------------------------------------- 1 | tests Package 2 | ============= 3 | 4 | :mod:`tests` Package 5 | -------------------- 6 | 7 | .. automodule:: django_prbac.tests 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | :mod:`test_decorators` Module 13 | ----------------------------- 14 | 15 | .. automodule:: django_prbac.tests.test_decorators 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | :mod:`test_fields` Module 21 | ------------------------- 22 | 23 | .. automodule:: django_prbac.tests.test_fields 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | :mod:`test_models` Module 29 | ------------------------- 30 | 31 | .. automodule:: django_prbac.tests.test_models 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | -------------------------------------------------------------------------------- /docs/apidoc/modules.rst: -------------------------------------------------------------------------------- 1 | django_prbac 2 | ============ 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | django_prbac 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-prbac documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Oct 29 17:06:26 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os, re 15 | from io import open 16 | 17 | os.environ['DJANGO_SETTINGS_MODULE'] = 'django_prbac.mock_settings' 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | def get_version(): 25 | version_re = re.compile(r"""^__version__ = (['"])(.*?)\1$""", re.M) 26 | thisdir = os.path.dirname(os.path.abspath(__file__)) 27 | path = os.path.join(thisdir, "../django_prbac/__init__.py") 28 | with open(path, encoding='utf-8') as fh: 29 | return version_re.search(fh.read()).group(2) 30 | 31 | # -- General configuration ----------------------------------------------------- 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | #needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be extensions 37 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 38 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'django-prbac' 54 | copyright = '2013, Dimagi' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = get_version() 62 | # The full version, including alpha/beta/rc tags. 63 | release = get_version() 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | #language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | #today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | #today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ['_build'] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all documents. 80 | #default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | #add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | #add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | #show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = 'sphinx' 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | #modindex_common_prefix = [] 98 | 99 | 100 | # -- Options for HTML output --------------------------------------------------- 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. 104 | html_theme = 'pyramid' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | #html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | #html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | #html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | #html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | #html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | #html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ['_static'] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | #html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | #html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | #html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | #html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | #html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | #html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | #html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | #html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | #html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | #html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | #html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | #html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = 'django-prbacdoc' 178 | 179 | 180 | # -- Options for LaTeX output -------------------------------------------------- 181 | 182 | latex_elements = { 183 | # The paper size ('letterpaper' or 'a4paper'). 184 | #'papersize': 'letterpaper', 185 | 186 | # The font size ('10pt', '11pt' or '12pt'). 187 | #'pointsize': '10pt', 188 | 189 | # Additional stuff for the LaTeX preamble. 190 | #'preamble': '', 191 | } 192 | 193 | # Grouping the document tree into LaTeX files. List of tuples 194 | # (source start file, target name, title, author, documentclass [howto/manual]). 195 | latex_documents = [ 196 | ('index', 'django-prbac.tex', 'django-prbac Documentation', 197 | 'Dimagi', 'manual'), 198 | ] 199 | 200 | # The name of an image file (relative to this directory) to place at the top of 201 | # the title page. 202 | #latex_logo = None 203 | 204 | # For "manual" documents, if this is true, then toplevel headings are parts, 205 | # not chapters. 206 | #latex_use_parts = False 207 | 208 | # If true, show page references after internal links. 209 | #latex_show_pagerefs = False 210 | 211 | # If true, show URL addresses after external links. 212 | #latex_show_urls = False 213 | 214 | # Documents to append as an appendix to all manuals. 215 | #latex_appendices = [] 216 | 217 | # If false, no module index is generated. 218 | #latex_domain_indices = True 219 | 220 | 221 | # -- Options for manual page output -------------------------------------------- 222 | 223 | # One entry per manual page. List of tuples 224 | # (source start file, name, description, authors, manual section). 225 | man_pages = [ 226 | ('index', 'django-prbac', 'django-prbac Documentation', 227 | ['Dimagi'], 1) 228 | ] 229 | 230 | # If true, show URL addresses after external links. 231 | #man_show_urls = False 232 | 233 | 234 | # -- Options for Texinfo output ------------------------------------------------ 235 | 236 | # Grouping the document tree into Texinfo files. List of tuples 237 | # (source start file, target name, title, author, 238 | # dir menu entry, description, category) 239 | texinfo_documents = [ 240 | ('index', 'django-prbac', 'django-prbac Documentation', 241 | 'Dimagi', 'django-prbac', 'One line description of project.', 242 | 'Miscellaneous'), 243 | ] 244 | 245 | # Documents to append as an appendix to all manuals. 246 | #texinfo_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | #texinfo_domain_indices = True 250 | 251 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 252 | #texinfo_show_urls = 'footnote' 253 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-prbac documentation master file, created by 2 | sphinx-quickstart on Tue Oct 29 17:06:26 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Parameterized Role-Based Access Control for Django 7 | ================================================== 8 | 9 | The django-prbac package provides the basic components of parameterized role-based 10 | access control for Django. The entirety of the system is contained in two classes 11 | of objects: 12 | 13 | 1. :class:`~django_prbac.models.Role` (representing users, groups, capabilities, and privileges) 14 | 2. :class:`~django_prbac.models.Grant` (representing memberships, containment, and permissions) 15 | 16 | If you are familiar with role-based access control (RBAC) then this is a minor, though 17 | foundational, enhancement to the non-parameterized version. It will often make the 18 | role graph much smaller and simpler, and will definitely allow much more 19 | powerful administration of the graph. 20 | 21 | Contents: 22 | 23 | .. toctree:: 24 | :glob: 25 | :maxdepth: 2 26 | 27 | setup 28 | tutorial 29 | apidoc/django_prbac 30 | 31 | Indices and tables 32 | ================== 33 | 34 | * :ref:`genindex` 35 | * :ref:`modindex` 36 | * :ref:`search` 37 | 38 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-prbac.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-prbac.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | 2 | Installation and Set Up 3 | ======================= 4 | 5 | There are no special steps to set up `django_prbac` beyond those for any Django app. 6 | 7 | The first step is to install the Python package via `pip` or `easy_install`:: 8 | 9 | $ pip install django-prbac 10 | 11 | Then add `django_prbac` to the `INSTALLED_APPS` in your settings module 12 | (this will be `settings.py` for default projects):: 13 | 14 | # in setting.py 15 | INSTALLED_APPS = [ 16 | ... 17 | 'django_prbac', 18 | ] 19 | 20 | Set up the database by running the migrations:: 21 | 22 | $ python manage.py migrate django_prbac 23 | 24 | If you wish, you can run the tests to check the health of your installation. This will not modify any 25 | of your data:: 26 | 27 | $ python manage.py test django_prbac --settings django_prbac.mock_settings 28 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. django-prbac documentation master file, created by 2 | sphinx-quickstart on Tue Oct 29 17:06:26 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Django PRBAC Tutorial 7 | ===================== 8 | 9 | Models of class |Role| represent capabilities, which may 10 | intuitively map to users, privileges, groups, and collections of privileges. 11 | 12 | Models of class |Grant| represent adding a user to a group, 13 | including a group in another group, and granting privileges to 14 | a user or group. 15 | 16 | 17 | Users 18 | ----- 19 | 20 | This library does not replace or modify the Django user system -- too many projects 21 | muck around with that, so it is safer and more flexible to leave it alone. Instead, 22 | you may give each user a corresponding |Role|:: 23 | 24 | >>> from django.contrib.auth.models import User 25 | >>> for user in User.objects.all(): 26 | Role.objects.create( 27 | name=user.username, 28 | slug=user.username, 29 | description='Role for django user: %s' % user.username 30 | ) 31 | 32 | This is very easy to automate with triggers or via the `UserProfile` feature of Django. 33 | 34 | 35 | Privileges 36 | ---------- 37 | 38 | A privilege is an actual thing that a user may do in the system. It is up to you 39 | to decide what these are and give them meaningful names and descriptions. 40 | For example, perhaps there is a granular permission of "may view reports":: 41 | 42 | >>> may_view_reports = Role.objects.create(name='may_view_reports', slug='may_view_reports', description='May view reports') 43 | 44 | >>> biyeun = Role.objects.get(name='biyeun') 45 | >>> kenn = Role.objects.get(name='kenn') 46 | 47 | >>> Grant.objects.create(from_role=biyeun, to_role=may_view_reports) 48 | 49 | >>> biyeun.has_privilege(may_view_reports) 50 | True 51 | 52 | >>> kenn.has_privilege(may_view_reports) 53 | False 54 | 55 | All of this is normal for RBAC (without parameterization) but with PRBAC we can make this 56 | privilege more granular:: 57 | 58 | >>> may_view_report = Role.objects.create(name='may_view_report', slug='may_view_report', parameters=set(['report_name'])) 59 | 60 | >>> Grant.objects.create(from_role=biyeun, to_role=may_view_report, assignment={'report_name': 'active_users'}) 61 | >>> Grant.objects.create(from_role=kenn, to_role=may_view_report, assignment={'report_name': 'submissions'}) 62 | 63 | >>> biyeun.has_privilege(may_view_report.instantiate({'report_name': 'active_users'})) 64 | True 65 | 66 | >>> biyeun.has_privilege(may_view_report.instantiate({'report_name': 'submissions'})) 67 | False 68 | 69 | >>> kenn.has_privilege(may_view_report.instantiate({'report_name': 'active_users'})) 70 | False 71 | 72 | >>> kenn.has_privilege(may_view_report.instantiate({'report_name': 'submissions'})) 73 | True 74 | 75 | 76 | Groups 77 | ------ 78 | 79 | A group of users may be represented as a |Role| as well:: 80 | 81 | >>> dimagineers = Role.objects.create(name='dimagineers', slug='dimagineers', description='Dimagi Engineers') 82 | 83 | >>> Grant.objects.create(from_role=kenn, to_role=dimagineers) 84 | >>> Grant.objects.create(from_role=biyeun, to_role=dimagineers) 85 | 86 | Now both `kenn` and `biyeun` are members of role `dimagineers`. 87 | 88 | >>> kenn.has_privilege(dimagineers) 89 | True 90 | >>> biyeun.has_privilege(dimagineers) 91 | True 92 | 93 | But groups can also be useful when parameterized, for granting a variety 94 | of parameterized privileges to a group of people. 95 | 96 | >>> may_edit_report = Role.objects.create( 97 | name='may_edit_report', 98 | description='May edit report', 99 | slug='may_edit_report', 100 | parameters=set(['report_name']), 101 | ) 102 | 103 | >>> report_superusers = Role.objects.create( 104 | name='report_superusers', 105 | description='Report Superusers', 106 | slug='report_superusers', 107 | parameters=set(['report_name']), 108 | ) 109 | 110 | >>> Grant.objects.create(from_role=report_superusers, to_role=may_edit_report) 111 | >>> Grant.objects.create(from_role=report_superusers, to_role=may_view_report) 112 | >>> Grant.objects.create( 113 | from_role=kenn, 114 | to_role=report_superusers, 115 | assignment={'report_name': 'dashboard'}, 116 | ) 117 | 118 | >>> kenn.has_privilege(may_view_report.instantiate({'report_name': 'dashboard'})) 119 | True 120 | >>> kenn.has_privilege(may_edit_report.instantiate({'report_name': 'dashboard'})) 121 | True 122 | 123 | .. |Role| :class:`~django_prbac.models.Role` 124 | .. |Grant| :class:`~django_prbac.models.Grant` 125 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from io import open 4 | from setuptools import setup, find_packages 5 | 6 | THISDIR = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | 9 | def get_version(): 10 | version_re = re.compile(r"""^__version__ = (['"])(.*?)\1$""", re.M) 11 | path = os.path.join(THISDIR, "django_prbac/__init__.py") 12 | with open(path, encoding="utf-8") as fh: 13 | return version_re.search(fh.read()).group(2) 14 | 15 | 16 | def get_readme(): 17 | path = os.path.join(THISDIR, "README.rst") 18 | with open(path, encoding="utf-8") as fh: 19 | return fh.read() 20 | 21 | 22 | setup( 23 | name='django-prbac', 24 | version=get_version(), 25 | description='Parameterized Role-Based Access Control for Django', 26 | long_description=get_readme(), 27 | author='Dimagi', 28 | author_email='dev@dimagi.com', 29 | url='http://github.com/dimagi/django-prbac', 30 | packages=find_packages(), 31 | zip_safe=False, 32 | install_requires=[ 33 | # avoid django 3 < 3.0.7 34 | # https://github.com/advisories/GHSA-hmr4-m2h5-33qx 35 | 'django>=3.0.7,<5', 36 | 'jsonfield>=1.0.3,<4', 37 | 'simplejson', 38 | ], 39 | classifiers=[ 40 | 'Development Status :: 3 - Alpha', 41 | 'Environment :: Web Environment', 42 | 'Framework :: Django', 43 | 'Intended Audience :: Developers', 44 | 'License :: OSI Approved :: BSD License', 45 | 'Operating System :: OS Independent', 46 | 'Programming Language :: Python', 47 | 'Programming Language :: Python :: 3', 48 | 'Programming Language :: Python :: 3.8', 49 | 'Programming Language :: Python :: 3.9', 50 | 'Programming Language :: Python :: 3.10', 51 | 'Programming Language :: Python :: 3.11', 52 | 'Topic :: Software Development :: Libraries :: Python Modules', 53 | ], 54 | options={"bdist_wheel": {"universal": "1"}}, 55 | ) 56 | --------------------------------------------------------------------------------