├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pyproject.toml ├── rules ├── __init__.py ├── apps.py ├── contrib │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── rest_framework.py │ └── views.py ├── permissions.py ├── predicates.py ├── py.typed ├── rulesets.py └── templatetags │ ├── __init__.py │ └── rules.py ├── runtests.sh ├── setup.cfg ├── setup.py ├── tests ├── manage.py ├── testapp │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── rules.py │ ├── settings.py │ ├── templates │ │ └── empty.html │ ├── urls.py │ └── views.py └── testsuite │ ├── __init__.py │ ├── contrib │ ├── __init__.py │ ├── test_admin.py │ ├── test_models.py │ ├── test_predicates.py │ ├── test_rest_framework.py │ ├── test_templatetags.py │ └── test_views.py │ ├── test_permissions.py │ ├── test_predicates.py │ └── test_rulesets.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Python 3.10 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.10" 20 | - uses: pre-commit/action@v2.0.2 21 | with: 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | tests: 25 | name: Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }} 26 | runs-on: ubuntu-latest 27 | needs: lint 28 | 29 | strategy: 30 | matrix: 31 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.10"] 32 | django-version: ["3.2", "4.2", "5.0", "5.1"] 33 | experimental: [false] 34 | include: 35 | - python-version: "3.12" 36 | django-version: "packaging" 37 | experimental: false 38 | - python-version: "3.12" 39 | django-version: "main" 40 | experimental: true 41 | exclude: 42 | # Unsupported Python versions for Django 5.0 43 | - python-version: 3.8 44 | django-version: 5.0 45 | - python-version: 3.9 46 | django-version: 5.0 47 | # Unsupported Python versions for Django 5.1 48 | - python-version: 3.8 49 | django-version: 5.1 50 | - python-version: 3.9 51 | django-version: 5.1 52 | 53 | steps: 54 | - uses: actions/checkout@v3 55 | 56 | - uses: actions/setup-python@v4 57 | with: 58 | python-version: ${{ matrix.python-version }} 59 | 60 | - name: Install dependencies 61 | run: | 62 | python -m pip install --upgrade pip setuptools wheel 63 | python -m pip install --upgrade coveralls tox tox-py tox-venv tox-gh-actions 64 | 65 | - name: Tox tests 66 | run: tox -v 67 | continue-on-error: ${{ matrix.experimental }} 68 | env: 69 | DJANGO: ${{ matrix.django-version }} 70 | 71 | - name: Upload coverage data to coveralls.io 72 | if: ${{ matrix.python-version != 'pypy-3.10' }} 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | COVERALLS_FLAG_NAME: run-${{ matrix.python-version }} 76 | COVERALLS_PARALLEL: true 77 | run: coveralls --service=github 78 | 79 | finish: 80 | name: Indicate completion to coveralls.io 81 | needs: tests 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Finalize publishing to coveralls.io 85 | uses: coverallsapp/github-action@master 86 | with: 87 | github-token: ${{ secrets.GITHUB_TOKEN }} 88 | parallel-finished: true 89 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to PyPi 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install build 22 | - name: Build 23 | run: | 24 | python -m build 25 | - name: Publish to PyPI Staging 26 | if: "github.event.release.prerelease" 27 | uses: pypa/gh-action-pypi-publish@release/v1 28 | with: 29 | user: '__token__' 30 | password: ${{ secrets.TEST_PYPI_TOKEN }} 31 | repository_url: https://test.pypi.org/legacy/ 32 | - name: Publish to PyPI 33 | if: "!github.event.release.prerelease" 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: '__token__' 37 | password: ${{ secrets.PYPI_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *~ 4 | ._* 5 | .coverage 6 | .DS_Store 7 | .Python 8 | __pycache__ 9 | dist/ 10 | docs/_build 11 | pip-log.txt 12 | .tox 13 | .eggs/ 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: check-toml 11 | - id: end-of-file-fixer 12 | - id: trailing-whitespace 13 | - repo: https://github.com/psf/black 14 | rev: 24.3.0 15 | hooks: 16 | - id: black 17 | language_version: python3 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.13.0 20 | hooks: 21 | - id: isort 22 | - repo: https://github.com/PyCQA/flake8 23 | rev: 7.0.0 24 | hooks: 25 | - id: flake8 26 | additional_dependencies: 27 | - flake8-bugbear 28 | - flake8-comprehensions 29 | - repo: https://github.com/pre-commit/mirrors-mypy 30 | rev: "v1.9.0" 31 | hooks: 32 | - id: mypy 33 | - repo: https://github.com/mgedmin/check-manifest 34 | rev: "0.49" 35 | hooks: 36 | - id: check-manifest 37 | args: [--no-build-isolation] 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ## Unreleased 5 | 6 | *Add a sentence for each interesting change in this section.* 7 | 8 | ------- 9 | 10 | ## v3.5.0 - 2024/09/02 11 | 12 | - Add support for Django 5.1 13 | 14 | ## v3.4.0 - 2024/05/18 15 | 16 | - Add support for Django 4.2 and 5.0 17 | - Add support for Python 3.11 and 3.12 18 | - Drop support for EOL Python 3.7 19 | - Drop support for EOL Django 2.2 and 4.0 20 | - Fix bug: type annotations were not used because ``py.typed`` was not always 21 | installed. 22 | 23 | ## v3.3.0 - 2022/03/23 24 | 25 | - Introduce type annotations for common APIs 26 | 27 | ## v3.2.1 - 2022/03/02 28 | 29 | - Fixed incorrect Django versions mentioned in CHANGELOG 30 | 31 | ## v3.2.0 - 2022/03/02 32 | 33 | - Added support for Python 3.10 34 | - Dropped support for Python 3.6 (EOL) 35 | - Dropped support for Django 3.0 and 3.1 (EOL) 36 | 37 | ## v3.1.0 - 2021/12/22 38 | 39 | - Added support for Django 4.0 40 | 41 | ## v3.0.0 - 2021/05/10 42 | 43 | - Dropped support for Python 2 44 | - Dropped support for Django versions before 2.2 45 | 46 | ## v2.2.0 - 2020/01/17 47 | 48 | - Added support for Django v3.0 49 | 50 | ## v2.1.0 - 2019/08/11 51 | 52 | - Added ability to automatically check for permissions in Drango Rest Framework 53 | viewsets. 54 | - Added ability to automatically check for permissions in Drango class-based 55 | views. 56 | - Added ability to automatically register permissions for models. 57 | - Added shim for "six" in anticipation for Django 3.0 dropping support for 58 | Python 2 and removing "six" from its codebase. 59 | 60 | ## v2.0.1 - 2018/12/07 61 | 62 | - Fixed issue with using ``rules`` in ``CreateView`` CBV 63 | 64 | ## v2.0.0 - 2018/07/22 65 | 66 | - Dropped support for Python 2.6 and 3.3 67 | - Dropped support for Django versions before 1.11 68 | - Removed ``SkipPredicate`` exception and ``skip`` method of ``Predicate`` 69 | - Removed ``replace_rule`` and related APIs 70 | - Added ``set_rule`` and related APIs to safely replace a rule without having 71 | to ensure one already exists 72 | - Added compatibility with Django v2.1 73 | - Re-introduced support for PyPy and PyPy 3 74 | - Changed Python and Django supported versions policy to exclude end-of-life 75 | versions. Support for EOL'd versions will be dropped in minor version 76 | updates of ``rules`` from now on. 77 | 78 | ## v1.4.0 - 2018/07/21 79 | 80 | - Fixed masking AttributeErrors raised from CBV get_object 81 | - Fixed compatibility with `inspect` in newer Python 3 versions 82 | - Added ability to replace rules and permissions 83 | 84 | ## v1.3.0 - 2017/12/13 85 | 86 | - Added support for Django 2.0 87 | - Added support for Django 1.11 and Python 3.6 88 | - Dropped support for PyPy and PyPy3 89 | 90 | ## v1.2.1 - 2017/05/13 91 | 92 | - Reverted "Fixed undesired caching in `is_group_member` factory" 93 | 94 | ## v1.2.0 - 2016/12/18 95 | 96 | - Added logging to predicates 97 | - Added support for Django 1.10 98 | - Fixed undesired caching in `is_group_member` factory 99 | 100 | ## v1.1.1 - 2015/12/07 101 | 102 | - Improved handling of skipped predicates 103 | 104 | ## v1.1.0 - 2015/12/05 105 | 106 | - Fixed regression that wouldn't short-circuit boolean expressions 107 | - Added support for Django 1.9 and Python 3.5 108 | - Added support for skipping predicates simply by returning `None`. 109 | The previous way of skipping predicates by raising `SkipPredicate` 110 | has been deprecated and will not be supported in a future release. 111 | 112 | ## v1.0.0 - 2015/10/06 113 | 114 | - Initial stable public release 115 | - Dropped support for Python 3.2 116 | - Added Django test suite 117 | - Added function-based view decorator 118 | - Added class-based view mixin 119 | 120 | ## v0.4 - 2015/02/16 121 | 122 | - Added support for creating predicates from partial functions 123 | - Added support for creating predicates from instance methods 124 | - Added predicate invocation context 125 | - Added support for automatically passing `self` to a predicate 126 | - Added support for discarding a predicate's result 127 | 128 | ## v0.3 - 2014/10/15 129 | 130 | - Added compatibility with PyPy and PyPy 3 131 | - Added `always_true()` and `always_false()` predicates 132 | - Added integration with Tox 133 | - Bug fixes 134 | 135 | ## v0.2 - 2014/06/09 136 | 137 | - Added compatibility with Python 3.4 138 | - Improved admin integration 139 | 140 | ## v0.1 - 2014/03/07 141 | 142 | - Initial public release 143 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | 2 | Please use:: 3 | 4 | $ python setup.py install 5 | 6 | See README.rst 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Akis Kesoglou 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include INSTALL 3 | include LICENSE 4 | include README.rst 5 | include runtests.sh 6 | recursive-include tests * 7 | global-exclude *.py[cod] __pycache__ 8 | global-exclude .coverage 9 | global-exclude .DS_Store 10 | exclude .pre-commit-config.yaml tox.ini 11 | recursive-include rules *.typed 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | rules 2 | ^^^^^ 3 | 4 | ``rules`` is a tiny but powerful app providing object-level permissions to 5 | Django, without requiring a database. At its core, it is a generic framework 6 | for building rule-based systems, similar to `decision trees`_. It can also be 7 | used as a standalone library in other contexts and frameworks. 8 | 9 | .. image:: https://img.shields.io/github/workflow/status/dfunckt/django-rules/CI/master 10 | :target: https://github.com/dfunckt/django-rules/actions 11 | .. image:: https://coveralls.io/repos/dfunckt/django-rules/badge.svg 12 | :target: https://coveralls.io/r/dfunckt/django-rules 13 | .. image:: https://img.shields.io/pypi/v/rules.svg 14 | :target: https://pypi.org/project/rules/ 15 | .. image:: https://img.shields.io/pypi/pyversions/rules.svg 16 | :target: https://pypi.org/project/rules/ 17 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 18 | :target: https://github.com/psf/black 19 | .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white 20 | :target: https://github.com/pre-commit/pre-commit 21 | 22 | .. _decision trees: http://wikipedia.org/wiki/Decision_tree 23 | 24 | 25 | Features 26 | ======== 27 | 28 | ``rules`` has got you covered. ``rules`` is: 29 | 30 | - **Documented**, **tested**, **reliable** and **easy to use**. 31 | - **Versatile**. Decorate callables to build complex graphs of predicates. 32 | Predicates can be any type of callable -- simple functions, lambdas, 33 | methods, callable class objects, partial functions, decorated functions, 34 | anything really. 35 | - **A good Django citizen**. Seamless integration with Django views, 36 | templates and the Admin for testing for object-level permissions. 37 | - **Efficient** and **smart**. No need to mess around with a database to figure 38 | out whether John really wrote that book. 39 | - **Simple**. Dive in the code. You'll need 10 minutes to figure out how it 40 | works. 41 | - **Powerful**. ``rules`` comes complete with advanced features, such as 42 | invocation context and storage for arbitrary data, skipping evaluation of 43 | predicates under specific conditions, logging of evaluated predicates and more! 44 | 45 | 46 | Table of Contents 47 | ================= 48 | 49 | - `Requirements`_ 50 | - `Upgrading from 2.x`_ 51 | - `Upgrading from 1.x`_ 52 | - `How to install`_ 53 | 54 | - `Configuring Django`_ 55 | 56 | - `Using Rules`_ 57 | 58 | - `Creating predicates`_ 59 | - `Dynamic predicates`_ 60 | - `Setting up rules`_ 61 | - `Combining predicates`_ 62 | 63 | - `Using Rules with Django`_ 64 | 65 | - `Permissions`_ 66 | - `Permissions in models`_ 67 | - `Permissions in views`_ 68 | - `Permissions and rules in templates`_ 69 | - `Permissions in the Admin`_ 70 | - `Permissions in Django Rest Framework`_ 71 | 72 | - `Advanced features`_ 73 | 74 | - `Custom rule sets`_ 75 | - `Invocation context`_ 76 | - `Binding "self"`_ 77 | - `Skipping predicates`_ 78 | - `Logging predicate evaluation`_ 79 | 80 | - `Best practices`_ 81 | - `API Reference`_ 82 | - `Licence`_ 83 | 84 | 85 | Requirements 86 | ============ 87 | 88 | ``rules`` requires Python 3.8 or newer. The last version to support Python 2.7 89 | is ``rules`` 2.2. It can optionally integrate with Django, in which case 90 | requires Django 3.2 or newer. 91 | 92 | *Note*: At any given moment in time, ``rules`` will maintain support for all 93 | currently supported Django versions, while dropping support for those versions 94 | that reached end-of-life in minor releases. See the `Supported Versions`_ 95 | section on Django Project website for the current state and timeline. 96 | 97 | .. _Supported Versions: https://www.djangoproject.com/download/#supported-versions 98 | 99 | 100 | Upgrading from 2.x 101 | ================== 102 | 103 | The are no significant changes between ``rules`` 2.x and 3.x except dropping 104 | support for Python 2, so before upgrading to 3.x you just need to make sure 105 | you're running a supported Python 3 version. 106 | 107 | 108 | Upgrading from 1.x 109 | ================== 110 | 111 | * Support for Python 2.6 and 3.3, and Django versions before 1.11 has been 112 | dropped. 113 | 114 | * The ``SkipPredicate`` exception and ``skip()`` method of ``Predicate``, 115 | that were used to signify that a predicate should be skipped, have been 116 | removed. You may return ``None`` from your predicate to achieve this. 117 | 118 | * The APIs to replace a rule's predicate have been renamed and their 119 | behaviour changed. ``replace_rule`` and ``replace_perm`` functions and 120 | ``replace_rule`` method of ``RuleSet`` have been renamed to ``set_rule``, 121 | ``set_perm`` and ``RuleSet.set_perm`` respectively. The old behaviour was 122 | to raise a ``KeyError`` if a rule by the given name did not exist. Since 123 | version 2.0 this has changed and you can safely use ``set_*`` to set a 124 | rule's predicate without having to ensure the rule exists first. 125 | 126 | 127 | How to install 128 | ============== 129 | 130 | Using pip: 131 | 132 | .. code:: bash 133 | 134 | $ pip install rules 135 | 136 | Manually: 137 | 138 | .. code:: bash 139 | 140 | $ git clone https://github.com/dfunckt/django-rules.git 141 | $ cd django-rules 142 | $ python setup.py install 143 | 144 | Run tests with: 145 | 146 | .. code:: bash 147 | 148 | $ ./runtests.sh 149 | 150 | You may also want to read `Best practices`_ for general advice on how to 151 | use ``rules``. 152 | 153 | 154 | Configuring Django 155 | ------------------ 156 | 157 | Add ``rules`` to ``INSTALLED_APPS``: 158 | 159 | .. code:: python 160 | 161 | INSTALLED_APPS = ( 162 | # ... 163 | 'rules', 164 | ) 165 | 166 | Add the authentication backend: 167 | 168 | .. code:: python 169 | 170 | AUTHENTICATION_BACKENDS = ( 171 | 'rules.permissions.ObjectPermissionBackend', 172 | 'django.contrib.auth.backends.ModelBackend', 173 | ) 174 | 175 | 176 | Using Rules 177 | =========== 178 | 179 | ``rules`` is based on the idea that you maintain a dict-like object that maps 180 | string keys used as identifiers of some kind, to callables, called 181 | *predicates*. This dict-like object is actually an instance of ``RuleSet`` and 182 | the predicates are instances of ``Predicate``. 183 | 184 | 185 | Creating predicates 186 | ------------------- 187 | 188 | Let's ignore rule sets for a moment and go ahead and define a predicate. The 189 | easiest way is with the ``@predicate`` decorator: 190 | 191 | .. code:: python 192 | 193 | >>> @rules.predicate 194 | >>> def is_book_author(user, book): 195 | ... return book.author == user 196 | ... 197 | >>> is_book_author 198 | 199 | 200 | This predicate will return ``True`` if the book's author is the given user, 201 | ``False`` otherwise. 202 | 203 | Predicates can be created from any callable that accepts anything from zero to 204 | two positional arguments: 205 | 206 | * ``fn(obj, target)`` 207 | * ``fn(obj)`` 208 | * ``fn()`` 209 | 210 | This is their generic form. If seen from the perspective of authorization in 211 | Django, the equivalent signatures are: 212 | 213 | * ``fn(user, obj)`` 214 | * ``fn(user)`` 215 | * ``fn()`` 216 | 217 | Predicates can do pretty much anything with the given arguments, but must 218 | always return ``True`` if the condition they check is true, ``False`` 219 | otherwise. ``rules`` comes with several predefined predicates that you may 220 | read about later on in `API Reference`_, that are mostly useful when dealing 221 | with `authorization in Django`_. 222 | 223 | 224 | Dynamic predicates 225 | ------------------- 226 | 227 | If needed predicates can be created dynamically depending on parameters: 228 | 229 | .. code:: python 230 | 231 | import rules 232 | 233 | 234 | def role_is(role_id): 235 | @rules.predicate 236 | def user_has_role(user): 237 | return user.role.id == role_id 238 | 239 | return user_has_role 240 | 241 | 242 | rules.add_perm("reports.view_report_abc", role_is(12)) 243 | rules.add_perm("reports.view_report_xyz", role_is(13)) 244 | 245 | 246 | Setting up rules 247 | ---------------- 248 | 249 | Let's pretend that we want to let authors edit or delete their books, but not 250 | books written by other authors. So, essentially, what determines whether an 251 | author *can edit* or *can delete* a given book is *whether they are its 252 | author*. 253 | 254 | In ``rules``, such requirements are modelled as *rules*. A *rule* is a map of 255 | a unique identifier (eg. "can edit") to a predicate. Rules are grouped 256 | together into a *rule set*. ``rules`` has two predefined rule sets: 257 | 258 | * A default rule set storing shared rules. 259 | * Another rule set storing rules that serve as permissions in a Django 260 | context. 261 | 262 | So, let's define our first couple of rules, adding them to the shared rule 263 | set. We can use the ``is_book_author`` predicate we defined earlier: 264 | 265 | .. code:: python 266 | 267 | >>> rules.add_rule('can_edit_book', is_book_author) 268 | >>> rules.add_rule('can_delete_book', is_book_author) 269 | 270 | Assuming we've got some data, we can now test our rules: 271 | 272 | .. code:: python 273 | 274 | >>> from django.contrib.auth.models import User 275 | >>> from books.models import Book 276 | >>> guidetodjango = Book.objects.get(isbn='978-1-4302-1936-1') 277 | >>> guidetodjango.author 278 | 279 | >>> adrian = User.objects.get(username='adrian') 280 | >>> rules.test_rule('can_edit_book', adrian, guidetodjango) 281 | True 282 | >>> rules.test_rule('can_delete_book', adrian, guidetodjango) 283 | True 284 | 285 | Nice... but not awesome. 286 | 287 | 288 | Combining predicates 289 | -------------------- 290 | 291 | Predicates by themselves are not so useful -- not more useful than any other 292 | function would be. Predicates, however, can be combined using binary operators 293 | to create more complex ones. Predicates support the following operators: 294 | 295 | * ``P1 & P2``: Returns a new predicate that returns ``True`` if *both* 296 | predicates return ``True``, otherwise ``False``. If P1 returns ``False``, 297 | P2 will not be evaluated. 298 | * ``P1 | P2``: Returns a new predicate that returns ``True`` if *any* of the 299 | predicates returns ``True``, otherwise ``False``. If P1 returns ``True``, 300 | P2 will not be evaluated. 301 | * ``P1 ^ P2``: Returns a new predicate that returns ``True`` if one of the 302 | predicates returns ``True`` and the other returns ``False``, otherwise 303 | ``False``. 304 | * ``~P``: Returns a new predicate that returns the negated result of the 305 | original predicate. 306 | 307 | Suppose the requirement for allowing a user to edit a given book was for them 308 | to be either the book's author, or a member of the "editors" group. Allowing 309 | users to delete a book should still be determined by whether the user is the 310 | book's author. 311 | 312 | With ``rules`` that's easy to implement. We'd have to define another 313 | predicate, that would return ``True`` if the given user is a member of the 314 | "editors" group, ``False`` otherwise. The built-in ``is_group_member`` factory 315 | will come in handy: 316 | 317 | .. code:: python 318 | 319 | >>> is_editor = rules.is_group_member('editors') 320 | >>> is_editor 321 | 322 | 323 | We could combine it with the ``is_book_author`` predicate to create a new one 324 | that checks for either condition: 325 | 326 | .. code:: python 327 | 328 | >>> is_book_author_or_editor = is_book_author | is_editor 329 | >>> is_book_author_or_editor 330 | 331 | 332 | We can now update our ``can_edit_book`` rule: 333 | 334 | .. code:: python 335 | 336 | >>> rules.set_rule('can_edit_book', is_book_author_or_editor) 337 | >>> rules.test_rule('can_edit_book', adrian, guidetodjango) 338 | True 339 | >>> rules.test_rule('can_delete_book', adrian, guidetodjango) 340 | True 341 | 342 | Let's see what happens with another user: 343 | 344 | .. code:: python 345 | 346 | >>> martin = User.objects.get(username='martin') 347 | >>> list(martin.groups.values_list('name', flat=True)) 348 | ['editors'] 349 | >>> rules.test_rule('can_edit_book', martin, guidetodjango) 350 | True 351 | >>> rules.test_rule('can_delete_book', martin, guidetodjango) 352 | False 353 | 354 | Awesome. 355 | 356 | So far, we've only used the underlying, generic framework for defining and 357 | testing rules. This layer is not at all specific to Django; it may be used in 358 | any context. There's actually no import of anything Django-related in the 359 | whole app (except in the ``rules.templatetags`` module). ``rules`` however can 360 | integrate tightly with Django to provide authorization. 361 | 362 | 363 | .. _authorization in Django: 364 | 365 | Using Rules with Django 366 | ======================= 367 | 368 | ``rules`` is able to provide object-level permissions in Django. It comes 369 | with an authorization backend and a couple template tags for use in your 370 | templates. 371 | 372 | 373 | Permissions 374 | ----------- 375 | 376 | In ``rules``, permissions are a specialised type of rules. You still define 377 | rules by creating and combining predicates. These rules however, must be added 378 | to a permissions-specific rule set that comes with ``rules`` so that they can 379 | be picked up by the ``rules`` authorization backend. 380 | 381 | 382 | Creating permissions 383 | ++++++++++++++++++++ 384 | 385 | The convention for naming permissions in Django is ``app_label.action_object``, 386 | and we like to adhere to that. Let's add rules for the ``books.change_book`` 387 | and ``books.delete_book`` permissions: 388 | 389 | .. code:: python 390 | 391 | >>> rules.add_perm('books.change_book', is_book_author | is_editor) 392 | >>> rules.add_perm('books.delete_book', is_book_author) 393 | 394 | See the difference in the API? ``add_perm`` adds to a permissions-specific 395 | rule set, whereas ``add_rule`` adds to a default shared rule set. It's 396 | important to know however, that these two rule sets are separate, meaning that 397 | adding a rule in one does not make it available to the other. 398 | 399 | 400 | Checking for permission 401 | +++++++++++++++++++++++ 402 | 403 | Let's go ahead and check whether ``adrian`` has change permission to the 404 | ``guidetodjango`` book: 405 | 406 | .. code:: python 407 | 408 | >>> adrian.has_perm('books.change_book', guidetodjango) 409 | False 410 | 411 | When you call the ``User.has_perm`` method, Django asks each backend in 412 | ``settings.AUTHENTICATION_BACKENDS`` whether a user has the given permission 413 | for the object. When queried for object permissions, Django's default 414 | authentication backend always returns ``False``. ``rules`` comes with an 415 | authorization backend, that is able to provide object-level permissions by 416 | looking into the permissions-specific rule set. 417 | 418 | Let's add the ``rules`` authorization backend in settings: 419 | 420 | .. code:: python 421 | 422 | AUTHENTICATION_BACKENDS = ( 423 | 'rules.permissions.ObjectPermissionBackend', 424 | 'django.contrib.auth.backends.ModelBackend', 425 | ) 426 | 427 | Now, checking again gives ``adrian`` the required permissions: 428 | 429 | .. code:: python 430 | 431 | >>> adrian.has_perm('books.change_book', guidetodjango) 432 | True 433 | >>> adrian.has_perm('books.delete_book', guidetodjango) 434 | True 435 | >>> martin.has_perm('books.change_book', guidetodjango) 436 | True 437 | >>> martin.has_perm('books.delete_book', guidetodjango) 438 | False 439 | 440 | **NOTE:** Calling `has_perm` on a superuser will ALWAYS return `True`. 441 | 442 | Permissions in models 443 | --------------------- 444 | 445 | **NOTE:** The features described in this section work on Python 3+ only. 446 | 447 | It is common to have a set of permissions for a model, like what Django offers with 448 | its default model permissions (such as *add*, *change* etc.). When using ``rules`` 449 | as the permission checking backend, you can declare object-level permissions for 450 | any model in a similar way, using a new ``Meta`` option. 451 | 452 | First, you need to switch your model's base and metaclass to the slightly extended 453 | versions provided in ``rules.contrib.models``. There are several classes and mixins 454 | you can use, depending on whether you're already using a custom base and/or metaclass 455 | for your models or not. The extensions are very slim and don't affect the models' 456 | behavior in any way other than making it register permissions. 457 | 458 | * If you're using the stock ``django.db.models.Model`` as base for your models, 459 | simply switch over to ``RulesModel`` and you're good to go. 460 | 461 | * If you already have a custom base class adding common functionality to your models, 462 | add ``RulesModelMixin`` to the classes it inherits from and set ``RulesModelBase`` 463 | as its metaclass, like so:: 464 | 465 | from django.db.models import Model 466 | from rules.contrib.models import RulesModelBase, RulesModelMixin 467 | 468 | class MyModel(RulesModelMixin, Model, metaclass=RulesModelBase): 469 | ... 470 | 471 | * If you're using a custom metaclass for your models, you'll already know how to 472 | make it inherit from ``RulesModelBaseMixin`` yourself. 473 | 474 | Then, create your models like so, assuming you're using ``RulesModel`` as base 475 | directly:: 476 | 477 | import rules 478 | from rules.contrib.models import RulesModel 479 | 480 | class Book(RulesModel): 481 | class Meta: 482 | rules_permissions = { 483 | "add": rules.is_staff, 484 | "read": rules.is_authenticated, 485 | } 486 | 487 | This would be equivalent to the following calls:: 488 | 489 | rules.add_perm("app_label.add_book", rules.is_staff) 490 | rules.add_perm("app_label.read_book", rules.is_authenticated) 491 | 492 | There are methods in ``RulesModelMixin`` that you can overwrite in order to customize 493 | how a model's permissions are registered. See the documented source code for details 494 | if you need this. 495 | 496 | Of special interest is the ``get_perm`` classmethod of ``RulesModelMixin``, which can 497 | be used to convert a permission type to the corresponding full permission name. If 498 | you need to query for some type of permission on a given model programmatically, 499 | this is handy:: 500 | 501 | if user.has_perm(Book.get_perm("read")): 502 | ... 503 | 504 | 505 | Permissions in views 506 | -------------------- 507 | 508 | ``rules`` comes with a set of view decorators to help you enforce 509 | authorization in your views. 510 | 511 | Using the function-based view decorator 512 | +++++++++++++++++++++++++++++++++++++++ 513 | 514 | For function-based views you can use the ``permission_required`` decorator: 515 | 516 | .. code:: python 517 | 518 | from django.shortcuts import get_object_or_404 519 | from rules.contrib.views import permission_required 520 | from posts.models import Post 521 | 522 | def get_post_by_pk(request, post_id): 523 | return get_object_or_404(Post, pk=post_id) 524 | 525 | @permission_required('posts.change_post', fn=get_post_by_pk) 526 | def post_update(request, post_id): 527 | # ... 528 | 529 | Usage is straight-forward, but there's one thing in the example above that 530 | stands out and this is the ``get_post_by_pk`` function. This function, given 531 | the current request and all arguments passed to the view, is responsible for 532 | fetching and returning the object to check permissions against -- i.e. the 533 | ``Post`` instance with PK equal to the given ``post_id`` in the example. 534 | This specific use-case is quite common so, to save you some typing, ``rules`` 535 | comes with a generic helper function that you can use to do this declaratively. 536 | The example below is equivalent to the one above: 537 | 538 | .. code:: python 539 | 540 | from rules.contrib.views import permission_required, objectgetter 541 | from posts.models import Post 542 | 543 | @permission_required('posts.change_post', fn=objectgetter(Post, 'post_id')) 544 | def post_update(request, post_id): 545 | # ... 546 | 547 | For more information on the decorator and helper function, refer to the 548 | ``rules.contrib.views`` module. 549 | 550 | Using the class-based view mixin 551 | ++++++++++++++++++++++++++++++++ 552 | 553 | Django includes a set of access mixins that you can use in your class-based 554 | views to enforce authorization. ``rules`` extends this framework to provide 555 | object-level permissions via a mixin, ``PermissionRequiredMixin``. 556 | 557 | The following example will automatically test for permission against the 558 | instance returned by the view's ``get_object`` method: 559 | 560 | .. code:: python 561 | 562 | from django.views.generic.edit import UpdateView 563 | from rules.contrib.views import PermissionRequiredMixin 564 | from posts.models import Post 565 | 566 | class PostUpdate(PermissionRequiredMixin, UpdateView): 567 | model = Post 568 | permission_required = 'posts.change_post' 569 | 570 | You can customise the object either by overriding ``get_object`` or 571 | ``get_permission_object``. 572 | 573 | For more information refer to the `Django documentation`_ and the 574 | ``rules.contrib.views`` module. 575 | 576 | .. _Django documentation: https://docs.djangoproject.com/en/stable/topics/auth/default/#limiting-access-to-logged-in-users 577 | 578 | Checking permission automatically based on view type 579 | ++++++++++++++++++++++++++++++++++++++++++++++++++++ 580 | 581 | If you use the mechanisms provided by ``rules.contrib.models`` to register permissions 582 | for your models as described in `Permissions in models`_, there's another convenient 583 | mixin for class-based views available for you. 584 | 585 | ``rules.contrib.views.AutoPermissionRequiredMixin`` can recognize the type of view 586 | it's used with and check for the corresponding permission automatically. 587 | 588 | This example view would, without any further configuration, automatically check for 589 | the ``"posts.change_post"`` permission, given that the app label is ``"posts"``:: 590 | 591 | from django.views.generic import UpdateView 592 | from rules.contrib.views import AutoPermissionRequiredMixin 593 | from posts.models import Post 594 | 595 | class UpdatePostView(AutoPermissionRequiredMixin, UpdateView): 596 | model = Post 597 | 598 | By default, the generic CRUD views from ``django.views.generic`` are mapped to the 599 | native Django permission types (*add*, *change*, *delete* and *view*). However, 600 | the pre-defined mappings can be extended, changed or replaced altogether when 601 | subclassing ``AutoPermissionRequiredMixin``. See the fully documented source code 602 | for details on how to do that properly. 603 | 604 | 605 | Permissions and rules in templates 606 | ---------------------------------- 607 | 608 | ``rules`` comes with two template tags to allow you to test for rules and 609 | permissions in templates. 610 | 611 | Add ``rules`` to your ``INSTALLED_APPS``: 612 | 613 | .. code:: python 614 | 615 | INSTALLED_APPS = ( 616 | # ... 617 | 'rules', 618 | ) 619 | 620 | Then, in your template:: 621 | 622 | {% load rules %} 623 | 624 | {% has_perm 'books.change_book' author book as can_edit_book %} 625 | {% if can_edit_book %} 626 | ... 627 | {% endif %} 628 | 629 | {% test_rule 'has_super_feature' user as has_super_feature %} 630 | {% if has_super_feature %} 631 | ... 632 | {% endif %} 633 | 634 | 635 | Permissions in the Admin 636 | ------------------------ 637 | 638 | If you've setup ``rules`` to be used with permissions in Django, you're almost 639 | set to also use ``rules`` to authorize any add/change/delete actions in the 640 | Admin. The Admin asks for *four* different permissions, depending on action: 641 | 642 | - ``.add_`` 643 | - ``.view_`` 644 | - ``.change_`` 645 | - ``.delete_`` 646 | - ```` 647 | 648 | *Note:* view permission is new in Django v2.1 and should not be added in versions before that. 649 | 650 | The first four are obvious. The fifth is the required permission for an app 651 | to be displayed in the Admin's "dashboard". Overriding it does not restrict access to the add, 652 | change or delete views. Here's some rules for our imaginary ``books`` app as an example: 653 | 654 | .. code:: python 655 | 656 | >>> rules.add_perm('books', rules.always_allow) 657 | >>> rules.add_perm('books.add_book', is_staff) 658 | >>> rules.add_perm('books.view_book', is_staff | has_secret_access_code) 659 | >>> rules.add_perm('books.change_book', is_staff) 660 | >>> rules.add_perm('books.delete_book', is_staff) 661 | 662 | Django Admin does not support object-permissions, in the sense that it will 663 | never ask for permission to perform an action *on an object*, only whether a 664 | user is allowed to act on (*any*) instances of a model. 665 | 666 | If you'd like to tell Django whether a user has permissions on a specific 667 | object, you'd have to override the following methods of a model's 668 | ``ModelAdmin``: 669 | 670 | - ``has_view_permission(user, obj=None)`` 671 | - ``has_change_permission(user, obj=None)`` 672 | - ``has_delete_permission(user, obj=None)`` 673 | 674 | ``rules`` comes with a custom ``ModelAdmin`` subclass, 675 | ``rules.contrib.admin.ObjectPermissionsModelAdmin``, that overrides these 676 | methods to pass on the edited model instance to the authorization backends, 677 | thus enabling permissions per object in the Admin: 678 | 679 | .. code:: python 680 | 681 | # books/admin.py 682 | from django.contrib import admin 683 | from rules.contrib.admin import ObjectPermissionsModelAdmin 684 | from .models import Book 685 | 686 | class BookAdmin(ObjectPermissionsModelAdmin): 687 | pass 688 | 689 | admin.site.register(Book, BookAdmin) 690 | 691 | Now this allows you to specify permissions like this: 692 | 693 | .. code:: python 694 | 695 | >>> rules.add_perm('books', rules.always_allow) 696 | >>> rules.add_perm('books.add_book', has_author_profile) 697 | >>> rules.add_perm('books.change_book', is_book_author_or_editor) 698 | >>> rules.add_perm('books.delete_book', is_book_author) 699 | 700 | To preserve backwards compatibility, Django will ask for either *view* or 701 | *change* permission. For maximum flexibility, ``rules`` behaves subtly 702 | different: ``rules`` will ask for the change permission if and only if no rule 703 | exists for the view permission. 704 | 705 | 706 | Permissions in Django Rest Framework 707 | ------------------------------------ 708 | 709 | Similar to ``rules.contrib.views.AutoPermissionRequiredMixin``, there is a 710 | ``rules.contrib.rest_framework.AutoPermissionViewSetMixin`` for viewsets in Django 711 | Rest Framework. The difference is that it doesn't derive permission from the type 712 | of view but from the API action (*create*, *retrieve* etc.) that's tried to be 713 | performed. Of course, it also requires you to declare your models as described in 714 | `Permissions in models`_. 715 | 716 | Here is a possible ``ModelViewSet`` for the ``Post`` model with fully automated CRUD 717 | permission checking:: 718 | 719 | from rest_framework.serializers import ModelSerializer 720 | from rest_framework.viewsets import ModelViewSet 721 | from rules.contrib.rest_framework import AutoPermissionViewSetMixin 722 | from posts.models import Post 723 | 724 | class PostSerializer(ModelSerializer): 725 | class Meta: 726 | model = Post 727 | fields = "__all__" 728 | 729 | class PostViewSet(AutoPermissionViewSetMixin, ModelViewSet): 730 | queryset = Post.objects.all() 731 | serializer_class = PostSerializer 732 | 733 | By default, the CRUD actions of ``ModelViewSet`` are mapped to the native 734 | Django permission types (*add*, *change*, *delete* and *view*). The ``list`` 735 | action has no permission checking enabled. However, the pre-defined mappings 736 | can be extended, changed or replaced altogether when using (or subclassing) 737 | ``AutoPermissionViewSetMixin``. Custom API actions defined via the ``@action`` 738 | decorator may then be mapped as well. See the fully documented source code for 739 | details on how to properly customize the default behavior. 740 | 741 | 742 | Advanced features 743 | ================= 744 | 745 | Custom rule sets 746 | ---------------- 747 | 748 | You may create as many rule sets as you need: 749 | 750 | .. code:: python 751 | 752 | >>> features = rules.RuleSet() 753 | 754 | And manipulate them by adding, removing, querying and testing rules: 755 | 756 | .. code:: python 757 | 758 | >>> features.rule_exists('has_super_feature') 759 | False 760 | >>> is_special_user = rules.is_group_member('special') 761 | >>> features.add_rule('has_super_feature', is_special_user) 762 | >>> 'has_super_feature' in features 763 | True 764 | >>> features['has_super_feature'] 765 | 766 | >>> features.test_rule('has_super_feature', adrian) 767 | True 768 | >>> features.remove_rule('has_super_feature') 769 | 770 | Note however that custom rule sets are *not available* in Django templates -- 771 | you need to provide integration yourself. 772 | 773 | 774 | Invocation context 775 | ------------------ 776 | 777 | A new context is created as a result of invoking ``Predicate.test()`` and is 778 | only valid for the duration of the invocation. A context is a simple ``dict`` 779 | that you can use to store arbitrary data, (eg. caching computed values, 780 | setting flags, etc.), that can be used by predicates later on in the chain. 781 | Inside a predicate function it can be used like so: 782 | 783 | .. code:: python 784 | 785 | >>> @predicate 786 | ... def mypred(a, b): 787 | ... value = compute_expensive_value(a) 788 | ... mypred.context['value'] = value 789 | ... return True 790 | 791 | Other predicates can later use stored values: 792 | 793 | .. code:: python 794 | 795 | >>> @predicate 796 | ... def myotherpred(a, b): 797 | ... value = myotherpred.context.get('value') 798 | ... if value is not None: 799 | ... return do_something_with_value(value) 800 | ... else: 801 | ... return do_something_without_value() 802 | 803 | ``Predicate.context`` provides a single ``args`` attribute that contains the 804 | arguments as given to ``test()`` at the beginning of the invocation. 805 | 806 | 807 | Binding "self" 808 | -------------- 809 | 810 | In a predicate's function body, you can refer to the predicate instance itself 811 | by its name, eg. ``is_book_author``. Passing ``bind=True`` as a keyword 812 | argument to the ``predicate`` decorator will let you refer to the predicate 813 | with ``self``, which is more convenient. Binding ``self`` is just syntactic 814 | sugar. As a matter of fact, the following two are equivalent: 815 | 816 | .. code:: python 817 | 818 | >>> @predicate 819 | ... def is_book_author(user, book): 820 | ... if is_book_author.context.args: 821 | ... return user == book.author 822 | ... return False 823 | 824 | >>> @predicate(bind=True) 825 | ... def is_book_author(self, user, book): 826 | ... if self.context.args: 827 | ... return user == book.author 828 | ... return False 829 | 830 | 831 | Skipping predicates 832 | ------------------- 833 | 834 | You may skip evaluation by returning ``None`` from your predicate: 835 | 836 | .. code:: python 837 | 838 | >>> @predicate(bind=True) 839 | ... def is_book_author(self, user, book): 840 | ... if len(self.context.args) > 1: 841 | ... return user == book.author 842 | ... else: 843 | ... return None 844 | 845 | Returning ``None`` signifies that the predicate need not be evaluated, thus 846 | leaving the predicate result up to that point unchanged. 847 | 848 | 849 | Logging predicate evaluation 850 | ---------------------------- 851 | 852 | ``rules`` can optionally be configured to log debug information as rules are 853 | evaluated to help with debugging your predicates. Messages are sent at the 854 | DEBUG level to the ``'rules'`` logger. The following `dictConfig`_ configures 855 | a console logger (place this in your project's `settings.py` if you're using 856 | `rules` with Django): 857 | 858 | .. code:: python 859 | 860 | LOGGING = { 861 | 'version': 1, 862 | 'disable_existing_loggers': False, 863 | 'handlers': { 864 | 'console': { 865 | 'level': 'DEBUG', 866 | 'class': 'logging.StreamHandler', 867 | }, 868 | }, 869 | 'loggers': { 870 | 'rules': { 871 | 'handlers': ['console'], 872 | 'level': 'DEBUG', 873 | 'propagate': True, 874 | }, 875 | }, 876 | } 877 | 878 | When this logger is active each individual predicate will have a log message 879 | printed when it is evaluated. 880 | 881 | .. _dictConfig: https://docs.python.org/3.6/library/logging.config.html#logging-config-dictschema 882 | 883 | 884 | Best practices 885 | ============== 886 | 887 | Before you can test for rules, these rules must be registered with a rule set, 888 | and for this to happen the modules containing your rule definitions must be 889 | imported. 890 | 891 | For complex projects with several predicates and rules, it may not be 892 | practical to define all your predicates and rules inside one module. It might 893 | be best to split them among any sub-components of your project. In a Django 894 | context, these sub-components could be the apps for your project. 895 | 896 | On the other hand, because importing predicates from all over the place in 897 | order to define rules can lead to circular imports and broken hearts, it's 898 | best to further split predicates and rules in different modules. 899 | 900 | ``rules`` may optionally be configured to autodiscover ``rules.py`` modules in 901 | your apps and import them at startup. To have ``rules`` do so, just edit your 902 | ``INSTALLED_APPS`` setting: 903 | 904 | .. code:: python 905 | 906 | INSTALLED_APPS = ( 907 | # replace 'rules' with: 908 | 'rules.apps.AutodiscoverRulesConfig', 909 | ) 910 | 911 | **Note:** On Python 2, you must also add the following to the top of your 912 | ``rules.py`` file, or you'll get import errors trying to import ``rules`` 913 | itself: 914 | 915 | .. code:: python 916 | 917 | from __future__ import absolute_import 918 | 919 | 920 | API Reference 921 | ============= 922 | 923 | The core APIs are accessible from the root ``rules`` module. Django-specific 924 | functionality for the Admin and views is available from ``rules.contrib``. 925 | 926 | 927 | Class ``rules.Predicate`` 928 | ------------------------- 929 | 930 | You create ``Predicate`` instances by passing in a callable: 931 | 932 | .. code:: python 933 | 934 | >>> def is_book_author(user, book): 935 | ... return book.author == user 936 | ... 937 | >>> pred = Predicate(is_book_author) 938 | >>> pred 939 | 940 | 941 | You may optionally provide a different name for the predicate that is used 942 | when inspecting it: 943 | 944 | .. code:: python 945 | 946 | >>> pred = Predicate(is_book_author, name='another_name') 947 | >>> pred 948 | 949 | 950 | Also, you may optionally provide ``bind=True`` in order to be able to access 951 | the predicate instance with ``self``: 952 | 953 | .. code:: python 954 | 955 | >>> def is_book_author(self, user, book): 956 | ... if self.context.args: 957 | ... return user == book.author 958 | ... return False 959 | ... 960 | >>> pred = Predicate(is_book_author, bind=True) 961 | >>> pred 962 | 963 | 964 | 965 | Instance methods 966 | ++++++++++++++++ 967 | 968 | ``test(obj=None, target=None)`` 969 | Returns the result of calling the passed in callable with zero, one or two 970 | positional arguments, depending on how many it accepts. 971 | 972 | 973 | Class ``rules.RuleSet`` 974 | ----------------------- 975 | 976 | ``RuleSet`` extends Python's built-in `dict`_ type. Therefore, you may create 977 | and use a rule set any way you'd use a dict. 978 | 979 | .. _dict: http://docs.python.org/library/stdtypes.html#mapping-types-dict 980 | 981 | 982 | Instance methods 983 | ++++++++++++++++ 984 | 985 | ``add_rule(name, predicate)`` 986 | Adds a predicate to the rule set, assigning it to the given rule name. 987 | Raises ``KeyError`` if another rule with that name already exists. 988 | 989 | ``set_rule(name, predicate)`` 990 | Set the rule with the given name, regardless if one already exists. 991 | 992 | ``remove_rule(name)`` 993 | Remove the rule with the given name. Raises ``KeyError`` if a rule with 994 | that name does not exist. 995 | 996 | ``rule_exists(name)`` 997 | Returns ``True`` if a rule with the given name exists, ``False`` otherwise. 998 | 999 | ``test_rule(name, obj=None, target=None)`` 1000 | Returns the result of calling ``predicate.test(obj, target)`` where 1001 | ``predicate`` is the predicate for the rule with the given name. Returns 1002 | ``False`` if a rule with the given name does not exist. 1003 | 1004 | Decorators 1005 | ---------- 1006 | 1007 | ``@predicate`` 1008 | Decorator that creates a predicate out of any callable: 1009 | 1010 | .. code:: python 1011 | 1012 | >>> @predicate 1013 | ... def is_book_author(user, book): 1014 | ... return book.author == user 1015 | ... 1016 | >>> is_book_author 1017 | 1018 | 1019 | Customising the predicate name: 1020 | 1021 | .. code:: python 1022 | 1023 | >>> @predicate(name='another_name') 1024 | ... def is_book_author(user, book): 1025 | ... return book.author == user 1026 | ... 1027 | >>> is_book_author 1028 | 1029 | 1030 | Binding ``self``: 1031 | 1032 | .. code:: python 1033 | 1034 | >>> @predicate(bind=True) 1035 | ... def is_book_author(self, user, book): 1036 | ... if 'user_has_special_flag' in self.context: 1037 | ... return self.context['user_has_special_flag'] 1038 | ... return book.author == user 1039 | 1040 | 1041 | Predefined predicates 1042 | --------------------- 1043 | 1044 | ``always_allow()``, ``always_true()`` 1045 | Always returns ``True``. 1046 | 1047 | ``always_deny()``, ``always_false()`` 1048 | Always returns ``False``. 1049 | 1050 | ``is_authenticated(user)`` 1051 | Returns the result of calling ``user.is_authenticated()``. Returns 1052 | ``False`` if the given user does not have an ``is_authenticated`` method. 1053 | 1054 | ``is_superuser(user)`` 1055 | Returns the result of calling ``user.is_superuser``. Returns ``False`` 1056 | if the given user does not have an ``is_superuser`` property. 1057 | 1058 | ``is_staff(user)`` 1059 | Returns the result of calling ``user.is_staff``. Returns ``False`` if the 1060 | given user does not have an ``is_staff`` property. 1061 | 1062 | ``is_active(user)`` 1063 | Returns the result of calling ``user.is_active``. Returns ``False`` if the 1064 | given user does not have an ``is_active`` property. 1065 | 1066 | ``is_group_member(*groups)`` 1067 | Factory that creates a new predicate that returns ``True`` if the given 1068 | user is a member of *all* the given groups, ``False`` otherwise. 1069 | 1070 | 1071 | Shortcuts 1072 | --------- 1073 | 1074 | Managing the shared rule set 1075 | ++++++++++++++++++++++++++++ 1076 | 1077 | ``add_rule(name, predicate)`` 1078 | Adds a rule to the shared rule set. See ``RuleSet.add_rule``. 1079 | 1080 | ``set_rule(name, predicate)`` 1081 | Set the rule with the given name from the shared rule set. See 1082 | ``RuleSet.set_rule``. 1083 | 1084 | ``remove_rule(name)`` 1085 | Remove a rule from the shared rule set. See ``RuleSet.remove_rule``. 1086 | 1087 | ``rule_exists(name)`` 1088 | Returns whether a rule exists in the shared rule set. See 1089 | ``RuleSet.rule_exists``. 1090 | 1091 | ``test_rule(name, obj=None, target=None)`` 1092 | Tests the rule with the given name. See ``RuleSet.test_rule``. 1093 | 1094 | 1095 | Managing the permissions rule set 1096 | +++++++++++++++++++++++++++++++++ 1097 | 1098 | ``add_perm(name, predicate)`` 1099 | Adds a rule to the permissions rule set. See ``RuleSet.add_rule``. 1100 | 1101 | ``set_perm(name, predicate)`` 1102 | Replace a rule from the permissions rule set. See ``RuleSet.set_rule``. 1103 | 1104 | ``remove_perm(name)`` 1105 | Remove a rule from the permissions rule set. See ``RuleSet.remove_rule``. 1106 | 1107 | ``perm_exists(name)`` 1108 | Returns whether a rule exists in the permissions rule set. See 1109 | ``RuleSet.rule_exists``. 1110 | 1111 | ``has_perm(name, user=None, obj=None)`` 1112 | Tests the rule with the given name. See ``RuleSet.test_rule``. 1113 | 1114 | 1115 | Licence 1116 | ======= 1117 | 1118 | ``django-rules`` is distributed under the MIT licence. 1119 | 1120 | Copyright (c) 2014 Akis Kesoglou 1121 | 1122 | Permission is hereby granted, free of charge, to any person 1123 | obtaining a copy of this software and associated documentation 1124 | files (the "Software"), to deal in the Software without 1125 | restriction, including without limitation the rights to use, 1126 | copy, modify, merge, publish, distribute, sublicense, and/or sell 1127 | copies of the Software, and to permit persons to whom the 1128 | Software is furnished to do so, subject to the following 1129 | conditions: 1130 | 1131 | The above copyright notice and this permission notice shall be 1132 | included in all copies or substantial portions of the Software. 1133 | 1134 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 1135 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 1136 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 1137 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 1138 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 1139 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 1140 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 1141 | OTHER DEALINGS IN THE SOFTWARE. 1142 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py38', 'py39', 'py310'] 3 | 4 | [tool.isort] 5 | profile = "black" 6 | known_django = "django" 7 | known_first_party = ['django-rules'] 8 | sections = ['FUTURE', 'STDLIB', 'DJANGO', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER'] 9 | skip=['migrations', '.git', '__pycache__', 'venv'] 10 | -------------------------------------------------------------------------------- /rules/__init__.py: -------------------------------------------------------------------------------- 1 | from .permissions import add_perm, has_perm, perm_exists, remove_perm, set_perm # noqa 2 | from .predicates import ( # noqa 3 | Predicate, 4 | always_allow, 5 | always_deny, 6 | always_false, 7 | always_true, 8 | is_active, 9 | is_authenticated, 10 | is_group_member, 11 | is_staff, 12 | is_superuser, 13 | predicate, 14 | ) 15 | from .rulesets import ( # noqa 16 | RuleSet, 17 | add_rule, 18 | remove_rule, 19 | rule_exists, 20 | set_rule, 21 | test_rule, 22 | ) 23 | 24 | VERSION = (3, 5, 0, "final", 1) 25 | -------------------------------------------------------------------------------- /rules/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RulesConfig(AppConfig): 5 | name = "rules" 6 | default = True 7 | 8 | 9 | class AutodiscoverRulesConfig(RulesConfig): 10 | default = False 11 | 12 | def ready(self): 13 | from django.utils.module_loading import autodiscover_modules 14 | 15 | autodiscover_modules("rules") 16 | -------------------------------------------------------------------------------- /rules/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfunckt/django-rules/6ce69d03bab6831ecfa194765b42110439ebe1bb/rules/contrib/__init__.py -------------------------------------------------------------------------------- /rules/contrib/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_permission_codename 3 | 4 | from ..permissions import perm_exists 5 | 6 | 7 | class ObjectPermissionsModelAdminMixin(object): 8 | def has_view_permission(self, request, obj=None): 9 | opts = self.opts 10 | codename = get_permission_codename("view", opts) 11 | perm = "%s.%s" % (opts.app_label, codename) 12 | if perm_exists(perm): 13 | return request.user.has_perm(perm, obj) 14 | else: 15 | return self.has_change_permission(request, obj) 16 | 17 | def has_change_permission(self, request, obj=None): 18 | opts = self.opts 19 | codename = get_permission_codename("change", opts) 20 | return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) 21 | 22 | def has_delete_permission(self, request, obj=None): 23 | opts = self.opts 24 | codename = get_permission_codename("delete", opts) 25 | return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) 26 | 27 | 28 | class ObjectPermissionsInlineModelAdminMixin(ObjectPermissionsModelAdminMixin): 29 | def has_change_permission(self, request, obj=None): # pragma: no cover 30 | opts = self.opts 31 | if opts.auto_created: 32 | for field in opts.fields: 33 | if field.rel and field.rel.to != self.parent_model: 34 | opts = field.rel.to._meta 35 | break 36 | codename = get_permission_codename("change", opts) 37 | return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) 38 | 39 | def has_delete_permission(self, request, obj=None): # pragma: no cover 40 | if self.opts.auto_created: 41 | return self.has_change_permission(request, obj) 42 | return super( 43 | ObjectPermissionsInlineModelAdminMixin, self 44 | ).has_delete_permission(request, obj) 45 | 46 | 47 | class ObjectPermissionsModelAdmin(ObjectPermissionsModelAdminMixin, admin.ModelAdmin): 48 | pass 49 | 50 | 51 | class ObjectPermissionsStackedInline( 52 | ObjectPermissionsInlineModelAdminMixin, admin.StackedInline 53 | ): 54 | pass 55 | 56 | 57 | class ObjectPermissionsTabularInline( 58 | ObjectPermissionsInlineModelAdminMixin, admin.TabularInline 59 | ): 60 | pass 61 | -------------------------------------------------------------------------------- /rules/contrib/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.db.models import Model 3 | from django.db.models.base import ModelBase 4 | 5 | from ..permissions import add_perm 6 | 7 | 8 | class RulesModelBaseMixin: 9 | """ 10 | Mixin for the metaclass of Django's Model that allows declaring object-level 11 | permissions in the model's Meta options. 12 | 13 | If set, the Meta attribute "rules_permissions" has to be a dictionary with 14 | permission types (like "add" or "change") as keys and predicates (like 15 | rules.is_staff) as values. Permissions are then registered with the rules 16 | framework automatically upon Model creation. 17 | 18 | This mixin can be used for creating custom metaclasses. 19 | """ 20 | 21 | def __new__(cls, name, bases, attrs, **kwargs): 22 | model_meta = attrs.get("Meta") 23 | if hasattr(model_meta, "rules_permissions"): 24 | perms = model_meta.rules_permissions 25 | del model_meta.rules_permissions 26 | if not isinstance(perms, dict): 27 | raise ImproperlyConfigured( 28 | "The rules_permissions Meta option of %s must be a dict, not %s." 29 | % (name, type(perms)) 30 | ) 31 | perms = perms.copy() 32 | else: 33 | perms = {} 34 | 35 | new_class = super().__new__(cls, name, bases, attrs, **kwargs) 36 | new_class._meta.rules_permissions = perms 37 | new_class.preprocess_rules_permissions(perms) 38 | for perm_type, predicate in perms.items(): 39 | add_perm(new_class.get_perm(perm_type), predicate) 40 | return new_class 41 | 42 | 43 | class RulesModelBase(RulesModelBaseMixin, ModelBase): 44 | """ 45 | A subclass of Django's ModelBase with the RulesModelBaseMixin mixed in. 46 | """ 47 | 48 | 49 | class RulesModelMixin: 50 | """ 51 | A mixin for Django's Model that adds hooks for stepping into the process of 52 | permission registration, which are called by the metaclass implementation in 53 | RulesModelBaseMixin. 54 | 55 | Use this mixin in a custom subclass of Model in order to change its behavior. 56 | """ 57 | 58 | @classmethod 59 | def get_perm(cls, perm_type): 60 | """Converts permission type ("add") to permission name ("app.add_modelname") 61 | 62 | :param perm_type: "add", "change", etc., or custom value 63 | :type perm_type: str 64 | :returns str: 65 | """ 66 | return "%s.%s_%s" % (cls._meta.app_label, perm_type, cls._meta.model_name) 67 | 68 | @classmethod 69 | def preprocess_rules_permissions(cls, perms): 70 | """May alter a permissions dict before it's processed further. 71 | 72 | Use this, for instance, to alter the supplied permissions or insert default 73 | values into the given dict. 74 | 75 | :param perms: 76 | Shallow-copied value of the rules_permissions model Meta option 77 | :type perms: dict 78 | """ 79 | 80 | 81 | class RulesModel(RulesModelMixin, Model, metaclass=RulesModelBase): 82 | """ 83 | An abstract model with RulesModelMixin mixed in, using RulesModelBase as metaclass. 84 | 85 | Use this as base for your models directly if you don't need to customize the 86 | behavior of RulesModelMixin and thus don't want to create a custom base class. 87 | """ 88 | 89 | class Meta: 90 | abstract = True 91 | -------------------------------------------------------------------------------- /rules/contrib/rest_framework.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied 2 | 3 | 4 | class AutoPermissionViewSetMixin: 5 | """ 6 | Enforces object-level permissions in ``rest_framework.viewsets.ViewSet``, 7 | deriving the permission type from the particular action to be performed.. 8 | 9 | As with ``rules.contrib.views.AutoPermissionRequiredMixin``, this only works when 10 | model permissions are registered using ``rules.contrib.models.RulesModelMixin``. 11 | """ 12 | 13 | # Maps API actions to model permission types. None as value skips permission 14 | # checks for the particular action. 15 | # This map needs to be extended when custom actions are implemented 16 | # using the @action decorator. 17 | # Extend or replace it in subclasses like so: 18 | # permission_type_map = { 19 | # **AutoPermissionViewSetMixin.permission_type_map, 20 | # "close": "change", 21 | # "reopen": "change", 22 | # } 23 | permission_type_map = { 24 | "create": "add", 25 | "destroy": "delete", 26 | "list": None, 27 | "partial_update": "change", 28 | "retrieve": "view", 29 | "update": "change", 30 | } 31 | 32 | def initial(self, *args, **kwargs): 33 | """Ensures user has permission to perform the requested action.""" 34 | super().initial(*args, **kwargs) 35 | 36 | if not self.request.user: 37 | # No user, don't check permission 38 | return 39 | 40 | # Get the handler for the HTTP method in use 41 | try: 42 | if self.request.method.lower() not in self.http_method_names: 43 | raise AttributeError 44 | handler = getattr(self, self.request.method.lower()) 45 | except AttributeError: 46 | # method not supported, will be denied anyway 47 | return 48 | 49 | try: 50 | perm_type = self.permission_type_map[self.action] 51 | except KeyError: 52 | raise ImproperlyConfigured( 53 | "AutoPermissionViewSetMixin tried to authorize a request with the " 54 | "{!r} action, but permission_type_map only contains: {!r}".format( 55 | self.action, self.permission_type_map 56 | ) 57 | ) 58 | if perm_type is None: 59 | # Skip permission checking for this action 60 | return 61 | 62 | # Determine whether we've to check object permissions (for detail actions) 63 | obj = None 64 | extra_actions = self.get_extra_actions() 65 | # We have to access the unbound function via __func__ 66 | if handler.__func__ in extra_actions: 67 | if handler.detail: 68 | obj = self.get_object() 69 | elif self.action not in ("create", "list"): 70 | obj = self.get_object() 71 | 72 | # Finally, check permission 73 | perm = self.get_queryset().model.get_perm(perm_type) 74 | if not self.request.user.has_perm(perm, obj): 75 | raise PermissionDenied 76 | -------------------------------------------------------------------------------- /rules/contrib/views.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import REDIRECT_FIELD_NAME, mixins 5 | from django.contrib.auth.views import redirect_to_login 6 | from django.core.exceptions import FieldError, ImproperlyConfigured, PermissionDenied 7 | from django.shortcuts import get_object_or_404 8 | from django.utils.encoding import force_str 9 | from django.views.generic import CreateView, DeleteView, DetailView, UpdateView 10 | 11 | # These are made available for convenience, as well as for use in Django 12 | # versions before 1.9. For usage help see Django's docs for 1.9 or later. 13 | from django.views.generic.edit import BaseCreateView 14 | 15 | LoginRequiredMixin = mixins.LoginRequiredMixin 16 | UserPassesTestMixin = mixins.UserPassesTestMixin 17 | 18 | 19 | class PermissionRequiredMixin(mixins.PermissionRequiredMixin): 20 | """ 21 | CBV mixin to provide object-level permission checking to views. Best used 22 | with views that inherit from ``SingleObjectMixin`` (``DetailView``, 23 | ``UpdateView``, etc.), though not required. 24 | 25 | The single requirement is for a ``get_object`` method to be available 26 | in the view. If there's no ``get_object`` method, permission checking 27 | is model-level, that is exactly like Django's ``PermissionRequiredMixin``. 28 | """ 29 | 30 | def get_permission_object(self): 31 | """ 32 | Override this method to provide the object to check for permission 33 | against. By default uses ``self.get_object()`` as provided by 34 | ``SingleObjectMixin``. Returns None if there's no ``get_object`` 35 | method. 36 | """ 37 | if not isinstance(self, BaseCreateView): 38 | # We do NOT want to call get_object in a BaseCreateView, see issue #85 39 | if hasattr(self, "get_object") and callable(self.get_object): 40 | # Requires SingleObjectMixin or equivalent ``get_object`` method 41 | return self.get_object() 42 | return None 43 | 44 | def has_permission(self): 45 | obj = self.get_permission_object() 46 | perms = self.get_permission_required() 47 | return self.request.user.has_perms(perms, obj) 48 | 49 | 50 | class AutoPermissionRequiredMixin(PermissionRequiredMixin): 51 | """ 52 | An extended variant of PermissionRequiredMixin which automatically determines 53 | the permission to check based on the type of view it's used with. 54 | 55 | It works by checking the current view for being an instance of a pre-defined 56 | list of view types. On a match, the corresponding permission type (such as 57 | "add" or "change") is converted into the full model-specific permission name 58 | and checked. See the permission_type_map attribute for the default view type -> 59 | permission type mappings. 60 | 61 | When a view using this mixin has an attribute ``permission_type``, that type 62 | is used directly and overwrites the permission_type_map for the particular 63 | view. A permission type of ``None`` (either as ``permission_type`` or in 64 | ``permission_type_map``) causes permission checking to be skipped. If the type 65 | of permission to check for should depend on dynamic factors other than the view 66 | type, you may overwrite the ``permission_type`` attribute with a ``@property``. 67 | 68 | The ``permission_required`` attribute behaves like it does in 69 | ``PermissionRequiredMixin`` and can be used to specify concrete permission name(s) 70 | to be checked in addition to the automatically derived one. 71 | 72 | NOTE: The model-based permission registration from ``rules.contrib.models`` 73 | must be used with the models for which you create views using this mixin, 74 | because the permission names are derived via ``RulesModelMixin.get_perm()`` 75 | internally. The second requirement is the presence of either an attribute 76 | ``model`` holding the ``Model`` the view acts on, or the ``get_queryset()`` 77 | method as provided by Django's ``SingleObjectMixin``. Hence with the normal 78 | model views, you don't need to care about anything. 79 | """ 80 | 81 | # These reflect Django's default model permissions. If needed, this list can be 82 | # extended or replaced entirely when subclassing, like so: 83 | # permission_type_map = [ 84 | # (SomeCustomViewType, "add"), 85 | # (SomeOtherCustomViewType, "some_fancy_action"), 86 | # *AutoPermissionRequiredMixin.permission_type_map, 87 | # ] 88 | # Note that ordering matters, which is why this is a list and not a dict. The 89 | # first entry for which isinstance(self, view_type) returns True will be used. 90 | permission_type_map = [ 91 | (CreateView, "add"), 92 | (UpdateView, "change"), 93 | (DeleteView, "delete"), 94 | (DetailView, "view"), 95 | ] 96 | 97 | def get_permission_required(self): 98 | """Adds the correct permission to check according to view type.""" 99 | try: 100 | perm_type = self.permission_type 101 | except AttributeError: 102 | # Perform auto-detection by view type 103 | for view_type, _perm_type in self.permission_type_map: 104 | if isinstance(self, view_type): 105 | perm_type = _perm_type 106 | break 107 | else: 108 | raise ImproperlyConfigured( 109 | "AutoPermissionRequiredMixin was used, but permission_type was " 110 | "neither set nor could be determined automatically for {0}. " 111 | "Consider setting permission_type on the view manually or " 112 | "adding {0} to the permission_type_map.".format( 113 | self.__class__.__name__ 114 | ) 115 | ) 116 | 117 | perms = [] 118 | if perm_type is not None: 119 | model = getattr(self, "model", None) 120 | if model is None: 121 | model = self.get_queryset().model 122 | perms.append(model.get_perm(perm_type)) 123 | 124 | # If additional permissions have been defined, consider them as well 125 | if self.permission_required is not None: 126 | perms.extend(super().get_permission_required()) 127 | return perms 128 | 129 | 130 | def objectgetter(model, attr_name="pk", field_name="pk"): 131 | """ 132 | Helper that returns a function suitable for use as the ``fn`` argument 133 | to the ``permission_required`` decorator. Internally uses 134 | ``get_object_or_404``, so keep in mind that this may raise ``Http404``. 135 | 136 | ``model`` can be a model class, manager or queryset. 137 | 138 | ``attr_name`` is the name of the view attribute. 139 | 140 | ``field_name`` is the model's field name by which the lookup is made, eg. 141 | "id", "slug", etc. 142 | """ 143 | 144 | def _getter(request, *view_args, **view_kwargs): 145 | if attr_name not in view_kwargs: 146 | raise ImproperlyConfigured( 147 | "Argument {0} is not available. Given arguments: [{1}]".format( 148 | attr_name, ", ".join(view_kwargs.keys()) 149 | ) 150 | ) 151 | try: 152 | return get_object_or_404(model, **{field_name: view_kwargs[attr_name]}) 153 | except FieldError: 154 | raise ImproperlyConfigured( 155 | "Model {0} has no field named {1}".format(model, field_name) 156 | ) 157 | 158 | return _getter 159 | 160 | 161 | def permission_required( 162 | perm, 163 | fn=None, 164 | login_url=None, 165 | raise_exception=False, 166 | redirect_field_name=REDIRECT_FIELD_NAME, 167 | ): 168 | """ 169 | View decorator that checks for the given permissions before allowing the 170 | view to execute. Use it like this:: 171 | 172 | from django.shortcuts import get_object_or_404 173 | from rules.contrib.views import permission_required 174 | from posts.models import Post 175 | 176 | def get_post_by_pk(request, post_id): 177 | return get_object_or_404(Post, pk=post_id) 178 | 179 | @permission_required('posts.change_post', fn=get_post_by_pk) 180 | def post_update(request, post_id): 181 | # ... 182 | 183 | ``perm`` is either a permission name as a string, or a list of permission 184 | names. 185 | 186 | ``fn`` is an optional callback that receives the same arguments as those 187 | passed to the decorated view and must return the object to check 188 | permissions against. If omitted, the decorator behaves just like Django's 189 | ``permission_required`` decorator, i.e. checks for model-level permissions. 190 | 191 | ``raise_exception`` is a boolean specifying whether to raise a 192 | ``django.core.exceptions.PermissionDenied`` exception if the check fails. 193 | You will most likely want to set this argument to ``True`` if you have 194 | specified a custom 403 response handler in your urlconf. If ``False``, 195 | the user will be redirected to the URL specified by ``login_url``. 196 | 197 | ``login_url`` is an optional custom URL to redirect the user to if 198 | permissions check fails. If omitted or empty, ``settings.LOGIN_URL`` is 199 | used. 200 | """ 201 | 202 | def decorator(view_func): 203 | @wraps(view_func) 204 | def _wrapped_view(request, *args, **kwargs): 205 | # Normalize to a list of permissions 206 | if isinstance(perm, str): 207 | perms = (perm,) 208 | else: 209 | perms = perm 210 | 211 | # Get the object to check permissions against 212 | if callable(fn): 213 | obj = fn(request, *args, **kwargs) 214 | else: # pragma: no cover 215 | obj = fn 216 | 217 | # Get the user 218 | user = request.user 219 | 220 | # Check for permissions and return a response 221 | if not user.has_perms(perms, obj): 222 | # User does not have a required permission 223 | if raise_exception: 224 | raise PermissionDenied() 225 | else: 226 | return _redirect_to_login( 227 | request, view_func.__name__, login_url, redirect_field_name 228 | ) 229 | else: 230 | # User has all required permissions -- allow the view to execute 231 | return view_func(request, *args, **kwargs) 232 | 233 | return _wrapped_view 234 | 235 | return decorator 236 | 237 | 238 | def _redirect_to_login(request, view_name, login_url, redirect_field_name): 239 | redirect_url = login_url or settings.LOGIN_URL 240 | if not redirect_url: # pragma: no cover 241 | raise ImproperlyConfigured( 242 | "permission_required({0}): You must either provide " 243 | 'the "login_url" argument to the "permission_required" ' 244 | "decorator or configure settings.LOGIN_URL".format(view_name) 245 | ) 246 | redirect_url = force_str(redirect_url) 247 | return redirect_to_login(request.get_full_path(), redirect_url, redirect_field_name) 248 | -------------------------------------------------------------------------------- /rules/permissions.py: -------------------------------------------------------------------------------- 1 | from .rulesets import RuleSet 2 | 3 | permissions = RuleSet() 4 | 5 | 6 | def add_perm(name, pred): 7 | permissions.add_rule(name, pred) 8 | 9 | 10 | def set_perm(name, pred): 11 | permissions.set_rule(name, pred) 12 | 13 | 14 | def remove_perm(name): 15 | permissions.remove_rule(name) 16 | 17 | 18 | def perm_exists(name): 19 | return permissions.rule_exists(name) 20 | 21 | 22 | def has_perm(name, *args, **kwargs): 23 | return permissions.test_rule(name, *args, **kwargs) 24 | 25 | 26 | class ObjectPermissionBackend(object): 27 | def authenticate(self, *args, **kwargs): 28 | return None 29 | 30 | def has_perm(self, user, perm, *args, **kwargs): 31 | return has_perm(perm, user, *args, **kwargs) 32 | 33 | def has_module_perms(self, user, app_label): 34 | return has_perm(app_label, user) 35 | -------------------------------------------------------------------------------- /rules/predicates.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import operator 3 | import threading 4 | from functools import partial, update_wrapper 5 | from inspect import getfullargspec, isfunction, ismethod 6 | from typing import Any, Callable, List, Optional, Tuple, Union 7 | 8 | logger = logging.getLogger("rules") 9 | 10 | 11 | def assert_has_kwonlydefaults(fn: Callable[..., Any], msg: str) -> None: 12 | argspec = getfullargspec(fn) 13 | if hasattr(argspec, "kwonlyargs"): 14 | if not argspec.kwonlyargs: 15 | return 16 | if not argspec.kwonlydefaults or len(argspec.kwonlyargs) > len( 17 | argspec.kwonlydefaults.keys() 18 | ): 19 | raise TypeError(msg) 20 | 21 | 22 | class Context(dict): 23 | def __init__(self, args: Tuple[Any, ...]) -> None: 24 | super(Context, self).__init__() 25 | self.args = args 26 | 27 | 28 | class localcontext(threading.local): 29 | def __init__(self) -> None: 30 | self.stack: List[Context] = [] 31 | 32 | 33 | _context = localcontext() 34 | 35 | 36 | class NoValueSentinel(object): 37 | def __bool__(self) -> bool: 38 | return False 39 | 40 | __nonzero__ = __bool__ # python 2 41 | 42 | 43 | NO_VALUE = NoValueSentinel() 44 | 45 | del NoValueSentinel 46 | 47 | 48 | class Predicate(object): 49 | fn: Callable[..., Any] 50 | num_args: int 51 | var_args: bool 52 | name: str 53 | 54 | def __init__( 55 | self, 56 | fn: Union["Predicate", Callable[..., Any]], 57 | name: Optional[str] = None, 58 | bind: bool = False, 59 | ) -> None: 60 | # fn can be a callable with any of the following signatures: 61 | # - fn(obj=None, target=None) 62 | # - fn(obj=None) 63 | # - fn() 64 | assert callable(fn), "The given predicate is not callable." 65 | innerfn = fn 66 | if isinstance(fn, Predicate): 67 | innerfn, num_args, var_args, name = ( 68 | fn.fn, 69 | fn.num_args, 70 | fn.var_args, 71 | name or fn.name, 72 | ) 73 | fn = innerfn 74 | elif isinstance(fn, partial): 75 | innerfn = fn.func 76 | argspec = getfullargspec(innerfn) 77 | var_args = argspec.varargs is not None 78 | num_args = len(argspec.args) - len(fn.args) 79 | if ismethod(innerfn): 80 | num_args -= 1 # skip `self` 81 | name = fn.func.__name__ 82 | elif ismethod(fn): 83 | argspec = getfullargspec(fn) 84 | var_args = argspec.varargs is not None 85 | num_args = len(argspec.args) - 1 # skip `self` 86 | elif isfunction(fn): 87 | argspec = getfullargspec(fn) 88 | var_args = argspec.varargs is not None 89 | num_args = len(argspec.args) 90 | elif isinstance(fn, object): 91 | innerfn = getattr(fn, "__call__") # noqa 92 | argspec = getfullargspec(innerfn) 93 | var_args = argspec.varargs is not None 94 | num_args = len(argspec.args) - 1 # skip `self` 95 | name = name or type(fn).__name__ 96 | else: # pragma: no cover 97 | # We handle all cases, so there's no way we can reach here 98 | raise TypeError("Incompatible predicate.") 99 | if bind: 100 | num_args -= 1 101 | assert_has_kwonlydefaults( 102 | innerfn, 103 | "The given predicate is missing defaults for keyword-only arguments", 104 | ) 105 | assert num_args <= 2, "Incompatible predicate." 106 | self.fn = fn 107 | self.num_args = num_args 108 | self.var_args = var_args 109 | self.name = name or fn.__name__ 110 | self.bind = bind 111 | 112 | def __repr__(self) -> str: 113 | return "<%s:%s object at %s>" % (type(self).__name__, str(self), hex(id(self))) 114 | 115 | def __str__(self) -> str: 116 | return self.name 117 | 118 | def __call__(self, *args, **kwargs) -> Any: 119 | # this method is defined as variadic in order to not mask the 120 | # underlying callable's signature that was most likely decorated 121 | # as a predicate. internally we consistently call ``_apply`` that 122 | # provides a single interface to the callable. 123 | if self.bind: 124 | return self.fn(self, *args, **kwargs) 125 | return self.fn(*args, **kwargs) 126 | 127 | @property 128 | def context(self) -> Optional[Context]: 129 | """ 130 | The currently active invocation context. A new context is created as a 131 | result of invoking ``test()`` and is only valid for the duration of 132 | the invocation. 133 | 134 | Can be used by predicates to store arbitrary data, eg. for caching 135 | computed values, setting flags, etc., that can be used by predicates 136 | later on in the chain. 137 | 138 | Inside a predicate function it can be used like so:: 139 | 140 | >>> @predicate 141 | ... def mypred(a, b): 142 | ... value = compute_expensive_value(a) 143 | ... mypred.context['value'] = value 144 | ... return True 145 | ... 146 | 147 | Other predicates can later use stored values:: 148 | 149 | >>> @predicate 150 | ... def myotherpred(a, b): 151 | ... value = myotherpred.context.get('value') 152 | ... if value is not None: 153 | ... return do_something_with_value(value) 154 | ... else: 155 | ... return do_something_without_value() 156 | ... 157 | 158 | """ 159 | try: 160 | return _context.stack[-1] 161 | except IndexError: 162 | return None 163 | 164 | def test(self, obj: Any = NO_VALUE, target: Any = NO_VALUE) -> bool: 165 | """ 166 | The canonical method to invoke predicates. 167 | """ 168 | args = tuple(arg for arg in (obj, target) if arg is not NO_VALUE) 169 | _context.stack.append(Context(args)) 170 | logger.debug("Testing %s", self) 171 | try: 172 | return bool(self._apply(*args)) 173 | finally: 174 | _context.stack.pop() 175 | 176 | def __and__(self, other) -> "Predicate": 177 | def AND(*args): 178 | return self._combine(other, operator.and_, args) 179 | 180 | return type(self)(AND, "(%s & %s)" % (self.name, other.name)) 181 | 182 | def __or__(self, other) -> "Predicate": 183 | def OR(*args): 184 | return self._combine(other, operator.or_, args) 185 | 186 | return type(self)(OR, "(%s | %s)" % (self.name, other.name)) 187 | 188 | def __xor__(self, other) -> "Predicate": 189 | def XOR(*args): 190 | return self._combine(other, operator.xor, args) 191 | 192 | return type(self)(XOR, "(%s ^ %s)" % (self.name, other.name)) 193 | 194 | def __invert__(self) -> "Predicate": 195 | def INVERT(*args): 196 | result = self._apply(*args) 197 | return None if result is None else not result 198 | 199 | if self.name.startswith("~"): 200 | name = self.name[1:] 201 | else: 202 | name = "~" + self.name 203 | return type(self)(INVERT, name) 204 | 205 | def _combine(self, other, op, args): 206 | self_result = self._apply(*args) 207 | if self_result is None: 208 | return other._apply(*args) 209 | 210 | # short-circuit evaluation 211 | if op is operator.and_ and not self_result: 212 | return False 213 | elif op is operator.or_ and self_result: 214 | return True 215 | 216 | other_result = other._apply(*args) 217 | if other_result is None: 218 | return self_result 219 | 220 | return op(self_result, other_result) 221 | 222 | def _apply(self, *args) -> Optional[bool]: 223 | # Internal method that is used to invoke the predicate with the 224 | # proper number of positional arguments, inside the current 225 | # invocation context. 226 | if self.var_args: 227 | callargs = args 228 | elif self.num_args > len(args): 229 | callargs = args + (None,) * (self.num_args - len(args)) 230 | else: 231 | callargs = args[: self.num_args] 232 | if self.bind: 233 | callargs = (self,) + callargs 234 | 235 | result = self.fn(*callargs) 236 | result = None if result is None else bool(result) 237 | 238 | logger.debug(" %s = %s", self, "skipped" if result is None else result) 239 | return result 240 | 241 | 242 | def predicate(fn=None, name=None, **options): 243 | """ 244 | Decorator that constructs a ``Predicate`` instance from any function:: 245 | 246 | >>> @predicate 247 | ... def is_book_author(user, book): 248 | ... return user == book.author 249 | ... 250 | 251 | >>> @predicate(bind=True) 252 | ... def is_book_author(self, user, book): 253 | ... if self.context.args: 254 | ... return user == book.author 255 | """ 256 | if not name and not callable(fn): 257 | name = fn 258 | fn = None 259 | 260 | def inner(fn): 261 | if isinstance(fn, Predicate): 262 | return fn 263 | p = Predicate(fn, name, **options) 264 | update_wrapper(p, fn) 265 | return p 266 | 267 | if fn: 268 | return inner(fn) 269 | else: 270 | return inner 271 | 272 | 273 | # Predefined predicates 274 | 275 | always_true = predicate(lambda: True, name="always_true") 276 | always_false = predicate(lambda: False, name="always_false") 277 | 278 | always_allow = predicate(lambda: True, name="always_allow") 279 | always_deny = predicate(lambda: False, name="always_deny") 280 | 281 | 282 | def is_bool_like(obj) -> bool: 283 | return hasattr(obj, "__bool__") or hasattr(obj, "__nonzero__") 284 | 285 | 286 | @predicate 287 | def is_authenticated(user) -> bool: 288 | if not hasattr(user, "is_authenticated"): 289 | return False # not a user model 290 | if not is_bool_like(user.is_authenticated): # pragma: no cover 291 | # Django < 1.10 292 | return user.is_authenticated() 293 | return user.is_authenticated 294 | 295 | 296 | @predicate 297 | def is_superuser(user) -> bool: 298 | if not hasattr(user, "is_superuser"): 299 | return False # swapped user model, doesn't support is_superuser 300 | return user.is_superuser 301 | 302 | 303 | @predicate 304 | def is_staff(user) -> bool: 305 | if not hasattr(user, "is_staff"): 306 | return False # swapped user model, doesn't support is_staff 307 | return user.is_staff 308 | 309 | 310 | @predicate 311 | def is_active(user) -> bool: 312 | if not hasattr(user, "is_active"): 313 | return False # swapped user model, doesn't support is_active 314 | return user.is_active 315 | 316 | 317 | def is_group_member(*groups) -> Callable[..., Any]: 318 | assert len(groups) > 0, "You must provide at least one group name" 319 | 320 | if len(groups) > 3: 321 | g = groups[:3] + ("...",) 322 | else: 323 | g = groups 324 | 325 | name = "is_group_member:%s" % ",".join(g) 326 | 327 | @predicate(name) 328 | def fn(user) -> bool: 329 | if not hasattr(user, "groups"): 330 | return False # swapped user model, doesn't support groups 331 | if not hasattr(user, "_group_names_cache"): # pragma: no cover 332 | user._group_names_cache = set(user.groups.values_list("name", flat=True)) 333 | return set(groups).issubset(user._group_names_cache) 334 | 335 | return fn 336 | -------------------------------------------------------------------------------- /rules/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfunckt/django-rules/6ce69d03bab6831ecfa194765b42110439ebe1bb/rules/py.typed -------------------------------------------------------------------------------- /rules/rulesets.py: -------------------------------------------------------------------------------- 1 | from .predicates import predicate 2 | 3 | 4 | class RuleSet(dict): 5 | def test_rule(self, name, *args, **kwargs): 6 | return name in self and self[name].test(*args, **kwargs) 7 | 8 | def rule_exists(self, name): 9 | return name in self 10 | 11 | def add_rule(self, name, pred): 12 | if name in self: 13 | raise KeyError("A rule with name `%s` already exists" % name) 14 | self[name] = pred 15 | 16 | def set_rule(self, name, pred): 17 | self[name] = pred 18 | 19 | def remove_rule(self, name): 20 | del self[name] 21 | 22 | def __setitem__(self, name, pred): 23 | fn = predicate(pred) 24 | super(RuleSet, self).__setitem__(name, fn) 25 | 26 | 27 | # Shared rule set 28 | 29 | default_rules = RuleSet() 30 | 31 | 32 | def add_rule(name, pred): 33 | default_rules.add_rule(name, pred) 34 | 35 | 36 | def set_rule(name, pred): 37 | default_rules.set_rule(name, pred) 38 | 39 | 40 | def remove_rule(name): 41 | default_rules.remove_rule(name) 42 | 43 | 44 | def rule_exists(name): 45 | return default_rules.rule_exists(name) 46 | 47 | 48 | def test_rule(name, *args, **kwargs): 49 | return default_rules.test_rule(name, *args, **kwargs) 50 | -------------------------------------------------------------------------------- /rules/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfunckt/django-rules/6ce69d03bab6831ecfa194765b42110439ebe1bb/rules/templatetags/__init__.py -------------------------------------------------------------------------------- /rules/templatetags/rules.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from ..rulesets import default_rules 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag 9 | def test_rule(name, obj=None, target=None): 10 | return default_rules.test_rule(name, obj, target) 11 | 12 | 13 | @register.simple_tag 14 | def has_perm(perm, user, obj=None): 15 | if not hasattr(user, "has_perm"): # pragma: no cover 16 | return False # swapped user model that doesn't support permissions 17 | else: 18 | return user.has_perm(perm, obj) 19 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # NOTE: Make sure you `pip install -e .` first 4 | coverage run tests/manage.py test --failfast -v2 testsuite "$@" \ 5 | && echo \ 6 | && coverage report -m 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length=88 6 | select = C,E,F,W,B,B950,I 7 | ignore = E203,E501,W503 8 | 9 | [coverage:run] 10 | source = rules 11 | 12 | [coverage:report] 13 | exclude_lines = 14 | pragma: no cover 15 | omit = 16 | */rules/apps.py 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from os.path import dirname, join 3 | 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from distutils.core import setup 8 | 9 | from rules import VERSION 10 | 11 | 12 | def get_version(version): 13 | """ 14 | Returns a PEP 386-compliant version number from ``version``. 15 | """ 16 | assert len(version) == 5 17 | assert version[3] in ("alpha", "beta", "rc", "final") 18 | 19 | parts = 2 if version[2] == 0 else 3 20 | main = ".".join(str(x) for x in version[:parts]) 21 | 22 | sub = "" 23 | if version[3] != "final": 24 | mapping = {"alpha": "a", "beta": "b", "rc": "c"} 25 | sub = mapping[version[3]] + str(version[4]) 26 | 27 | return main + sub 28 | 29 | 30 | with open(join(dirname(__file__), "README.rst")) as f: 31 | long_description = f.read() 32 | 33 | 34 | setup( 35 | name="rules", 36 | description="Awesome Django authorization, without the database", 37 | version=get_version(VERSION), 38 | long_description=long_description, 39 | url="http://github.com/dfunckt/django-rules", 40 | author="Akis Kesoglou", 41 | author_email="akiskesoglou@gmail.com", 42 | maintainer="Akis Kesoglou", 43 | maintainer_email="akiskesoglou@gmail.com", 44 | license="MIT", 45 | zip_safe=False, 46 | packages=[ 47 | "rules", 48 | "rules.templatetags", 49 | "rules.contrib", 50 | ], 51 | include_package_data=True, 52 | classifiers=[ 53 | "Development Status :: 5 - Production/Stable", 54 | "Environment :: Web Environment", 55 | "Framework :: Django", 56 | "Intended Audience :: Developers", 57 | "License :: OSI Approved :: MIT License", 58 | "Operating System :: OS Independent", 59 | "Programming Language :: Python", 60 | "Programming Language :: Python :: 3", 61 | "Programming Language :: Python :: 3.8", 62 | "Programming Language :: Python :: 3.9", 63 | "Programming Language :: Python :: 3.10", 64 | "Programming Language :: Python :: 3.11", 65 | "Programming Language :: Python :: 3.12", 66 | ], 67 | ) 68 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfunckt/django-rules/6ce69d03bab6831ecfa194765b42110439ebe1bb/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.contrib import admin 4 | 5 | from rules.contrib.admin import ObjectPermissionsModelAdmin 6 | 7 | from .models import Book 8 | 9 | 10 | class BookAdmin(ObjectPermissionsModelAdmin): 11 | pass 12 | 13 | 14 | admin.site.register(Book, BookAdmin) 15 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-05 08:04 2 | 3 | import sys 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="Book", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ("isbn", models.CharField(max_length=50, unique=True)), 32 | ("title", models.CharField(max_length=100)), 33 | ( 34 | "author", 35 | models.ForeignKey( 36 | on_delete=django.db.models.deletion.CASCADE, 37 | to=settings.AUTH_USER_MODEL, 38 | ), 39 | ), 40 | ], 41 | ), 42 | ] 43 | 44 | # TestModel doesn't work under Python 2 45 | if sys.version_info.major >= 3: 46 | import rules.contrib.models 47 | 48 | operations += [ 49 | migrations.CreateModel( 50 | name="TestModel", 51 | fields=[ 52 | ( 53 | "id", 54 | models.AutoField( 55 | auto_created=True, 56 | primary_key=True, 57 | serialize=False, 58 | verbose_name="ID", 59 | ), 60 | ), 61 | ], 62 | bases=(rules.contrib.models.RulesModelMixin, models.Model), 63 | ), 64 | ] 65 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfunckt/django-rules/6ce69d03bab6831ecfa194765b42110439ebe1bb/tests/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | 6 | import rules 7 | from rules.contrib.models import RulesModel 8 | 9 | 10 | class Book(models.Model): 11 | isbn = models.CharField(max_length=50, unique=True) 12 | title = models.CharField(max_length=100) 13 | author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 14 | 15 | def __str__(self): 16 | return self.title 17 | 18 | 19 | class TestModel(RulesModel): 20 | class Meta: 21 | rules_permissions = {"add": rules.always_true, "view": rules.always_true} 22 | 23 | @classmethod 24 | def preprocess_rules_permissions(cls, perms): 25 | perms["custom"] = rules.always_true 26 | -------------------------------------------------------------------------------- /tests/testapp/rules.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import rules 4 | 5 | # Predicates 6 | 7 | 8 | @rules.predicate 9 | def is_book_author(user, book): 10 | if not book: 11 | return False 12 | return book.author == user 13 | 14 | 15 | @rules.predicate 16 | def is_boss(user): 17 | return user.is_superuser 18 | 19 | 20 | is_editor = rules.is_group_member("editors") 21 | 22 | # Rules 23 | 24 | rules.add_rule("change_book", is_book_author | is_editor) 25 | rules.add_rule("delete_book", is_book_author) 26 | rules.add_rule("create_book", is_boss) 27 | 28 | # Permissions 29 | 30 | rules.add_perm("testapp.change_book", is_book_author | is_editor) 31 | rules.add_perm("testapp.delete_book", is_book_author) 32 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | from os.path import abspath, dirname 2 | 3 | BASE_DIR = dirname(dirname(abspath(__file__))) 4 | 5 | DEBUG = True 6 | 7 | ADMINS = [ 8 | ("test@example.com", "Administrator"), 9 | ] 10 | 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | }, 16 | } 17 | 18 | INSTALLED_APPS = [ 19 | "django.contrib.admin", 20 | "django.contrib.auth", 21 | "django.contrib.contenttypes", 22 | "django.contrib.messages", 23 | "django.contrib.sessions", 24 | "rules", 25 | "testapp", 26 | ] 27 | 28 | MIDDLEWARE = [ 29 | "django.contrib.sessions.middleware.SessionMiddleware", 30 | "django.contrib.auth.middleware.AuthenticationMiddleware", 31 | "django.contrib.messages.middleware.MessageMiddleware", 32 | ] 33 | 34 | AUTHENTICATION_BACKENDS = [ 35 | "rules.permissions.ObjectPermissionBackend", 36 | "django.contrib.auth.backends.ModelBackend", 37 | ] 38 | 39 | CACHE_BACKEND = "locmem://" 40 | 41 | SECRET_KEY = "thats-a-secret" 42 | 43 | ROOT_URLCONF = "testapp.urls" 44 | 45 | TEMPLATES = [ 46 | { 47 | "BACKEND": "django.template.backends.django.DjangoTemplates", 48 | "DIRS": [], 49 | "APP_DIRS": True, 50 | "OPTIONS": { 51 | "debug": DEBUG, 52 | "context_processors": [ 53 | "django.template.context_processors.debug", 54 | "django.template.context_processors.request", 55 | "django.contrib.auth.context_processors.auth", 56 | "django.contrib.messages.context_processors.messages", 57 | ], 58 | }, 59 | }, 60 | ] 61 | -------------------------------------------------------------------------------- /tests/testapp/templates/empty.html: -------------------------------------------------------------------------------- 1 | OK 2 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import re_path 3 | 4 | from .views import ( 5 | BookCreateView, 6 | BookDeleteView, 7 | BookUpdateErrorView, 8 | BookUpdateView, 9 | ViewThatRaises, 10 | ViewWithPermissionList, 11 | change_book, 12 | delete_book, 13 | view_that_raises, 14 | view_with_object, 15 | view_with_permission_list, 16 | ) 17 | 18 | admin.autodiscover() 19 | 20 | urlpatterns = [ 21 | re_path(r"^admin/", admin.site.urls), 22 | # Function-based views 23 | re_path(r"^(?P\d+)/change/$", change_book, name="change_book"), 24 | re_path(r"^(?P\d+)/delete/$", delete_book, name="delete_book"), 25 | re_path(r"^(?P\d+)/raise/$", view_that_raises, name="view_that_raises"), 26 | re_path(r"^(?P\d+)/object/$", view_with_object, name="view_with_object"), 27 | re_path( 28 | r"^(?P\d+)/list/$", 29 | view_with_permission_list, 30 | name="view_with_permission_list", 31 | ), 32 | # Class-based views 33 | re_path(r"^cbv/create/$", BookCreateView.as_view(), name="cbv.create_book"), 34 | re_path( 35 | r"^cbv/(?P\d+)/change/$", 36 | BookUpdateView.as_view(), 37 | name="cbv.change_book", 38 | ), 39 | re_path( 40 | r"^cbv/(?P\d+)/delete/$", 41 | BookDeleteView.as_view(), 42 | name="cbv.delete_book", 43 | ), 44 | re_path( 45 | r"^cbv/(?P\d+)/raise/$", 46 | ViewThatRaises.as_view(), 47 | name="cbv.view_that_raises", 48 | ), 49 | re_path( 50 | r"^cbv/(?P\d+)/list/$", 51 | ViewWithPermissionList.as_view(), 52 | name="cbv.view_with_permission_list", 53 | ), 54 | re_path( 55 | r"^cbv/(?P\d+)/change-error/$", 56 | BookUpdateErrorView.as_view(), 57 | name="cbv.change_book_error", 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.http import HttpResponse 4 | from django.views.generic.edit import CreateView, DeleteView, UpdateView 5 | 6 | from rules.contrib.views import ( 7 | LoginRequiredMixin, 8 | PermissionRequiredMixin, 9 | objectgetter, 10 | permission_required, 11 | ) 12 | 13 | from .models import Book 14 | 15 | 16 | class BookMixin(object): 17 | def get_object(self): 18 | return Book.objects.get(pk=self.kwargs["book_id"]) 19 | 20 | 21 | class BookMixinWithError(object): 22 | def get_object(self): 23 | raise AttributeError("get_object") 24 | 25 | 26 | @permission_required("testapp.change_book", fn=objectgetter(Book, "book_id")) 27 | def change_book(request, book_id): 28 | return HttpResponse("OK") 29 | 30 | 31 | class BookCreateView( 32 | LoginRequiredMixin, PermissionRequiredMixin, BookMixin, CreateView 33 | ): 34 | fields = ["title"] 35 | template_name = "empty.html" 36 | permission_required = "testapp.create_book" 37 | 38 | 39 | class BookUpdateView( 40 | LoginRequiredMixin, PermissionRequiredMixin, BookMixin, UpdateView 41 | ): 42 | fields = ["title"] 43 | template_name = "empty.html" 44 | permission_required = "testapp.change_book" 45 | 46 | 47 | class BookUpdateErrorView( 48 | LoginRequiredMixin, PermissionRequiredMixin, BookMixinWithError, UpdateView 49 | ): 50 | fields = ["title"] 51 | template_name = "empty.html" 52 | permission_required = "testapp.change_book" 53 | 54 | 55 | @permission_required("testapp.delete_book", fn=objectgetter(Book, "book_id")) 56 | def delete_book(request, book_id): 57 | return HttpResponse("OK") 58 | 59 | 60 | class BookDeleteView( 61 | LoginRequiredMixin, PermissionRequiredMixin, BookMixin, DeleteView 62 | ): 63 | template_name = "empty.html" 64 | permission_required = "testapp.delete_book" 65 | 66 | 67 | @permission_required( 68 | "testapp.delete_book", fn=objectgetter(Book, "book_id"), raise_exception=True 69 | ) 70 | def view_that_raises(request, book_id): 71 | return HttpResponse("OK") 72 | 73 | 74 | class ViewThatRaises( 75 | LoginRequiredMixin, PermissionRequiredMixin, BookMixin, DeleteView 76 | ): 77 | template_name = "empty.html" 78 | raise_exception = True 79 | permission_required = "testapp.delete_book" 80 | 81 | 82 | @permission_required( 83 | ["testapp.change_book", "testapp.delete_book"], fn=objectgetter(Book, "book_id") 84 | ) 85 | def view_with_permission_list(request, book_id): 86 | return HttpResponse("OK") 87 | 88 | 89 | class ViewWithPermissionList( 90 | LoginRequiredMixin, PermissionRequiredMixin, BookMixin, DeleteView 91 | ): 92 | template_name = "empty.html" 93 | permission_required = ["testapp.change_book", "testapp.delete_book"] 94 | 95 | 96 | @permission_required("testapp.delete_book", fn=objectgetter(Book, "book_id")) 97 | def view_with_object(request, book_id): 98 | return HttpResponse("OK") 99 | -------------------------------------------------------------------------------- /tests/testsuite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfunckt/django-rules/6ce69d03bab6831ecfa194765b42110439ebe1bb/tests/testsuite/__init__.py -------------------------------------------------------------------------------- /tests/testsuite/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group, User 2 | 3 | import testapp.rules # noqa .imported to register rules 4 | from testapp.models import Book 5 | 6 | ISBN = "978-1-4302-1936-1" 7 | 8 | 9 | class TestData: 10 | @classmethod 11 | def setUpTestData(cls): 12 | adrian = User.objects.create_user( 13 | "adrian", password="secr3t", is_superuser=True, is_staff=True 14 | ) 15 | 16 | martin = User.objects.create_user("martin", password="secr3t", is_staff=True) 17 | 18 | editors = Group.objects.create(name="editors") 19 | martin.groups.add(editors) 20 | 21 | Book.objects.create( 22 | isbn=ISBN, title="The Definitive Guide to Django", author=adrian 23 | ) 24 | -------------------------------------------------------------------------------- /tests/testsuite/contrib/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from . import TestData 5 | 6 | 7 | class ModelAdminTests(TestData, TestCase): 8 | def test_change_book(self): 9 | # adrian can change his book as its author 10 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 11 | response = self.client.get(reverse("admin:testapp_book_change", args=(1,))) 12 | self.assertEqual(response.status_code, 200) 13 | 14 | # martin can change adrian's book as an editor 15 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 16 | response = self.client.get(reverse("admin:testapp_book_change", args=(1,))) 17 | self.assertEqual(response.status_code, 200) 18 | 19 | def test_delete_book(self): 20 | # martin can *not* delete adrian's book 21 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 22 | response = self.client.get(reverse("admin:testapp_book_delete", args=(1,))) 23 | self.assertEqual(response.status_code, 403) 24 | 25 | # adrian can delete his book as its author 26 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 27 | response = self.client.get(reverse("admin:testapp_book_delete", args=(1,))) 28 | self.assertEqual(response.status_code, 200) 29 | -------------------------------------------------------------------------------- /tests/testsuite/contrib/test_models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.test import TestCase 5 | 6 | import rules 7 | 8 | 9 | class RulesModelTests(TestCase): 10 | def test_preprocess(self): 11 | self.assertTrue(rules.perm_exists("testapp.add_testmodel")) 12 | self.assertTrue(rules.perm_exists("testapp.custom_testmodel")) 13 | 14 | def test_invalid_config(self): 15 | from rules.contrib.models import RulesModel 16 | 17 | with self.assertRaises(ImproperlyConfigured): 18 | 19 | class InvalidTestModel(RulesModel): 20 | class Meta: 21 | app_label = "testapp" 22 | rules_permissions = "invalid" 23 | -------------------------------------------------------------------------------- /tests/testsuite/contrib/test_predicates.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | 4 | from rules.predicates import ( 5 | is_active, 6 | is_authenticated, 7 | is_group_member, 8 | is_staff, 9 | is_superuser, 10 | ) 11 | 12 | from . import TestData 13 | 14 | 15 | class SwappedUser(object): 16 | pass 17 | 18 | 19 | class PredicateTests(TestData, TestCase): 20 | def test_is_authenticated(self): 21 | assert is_authenticated(User.objects.get(username="adrian")) 22 | assert not is_authenticated(SwappedUser()) 23 | 24 | def test_is_superuser(self): 25 | assert is_superuser(User.objects.get(username="adrian")) 26 | assert not is_superuser(SwappedUser()) 27 | 28 | def test_is_staff(self): 29 | assert is_staff(User.objects.get(username="adrian")) 30 | assert not is_staff(SwappedUser()) 31 | 32 | def test_is_active(self): 33 | assert is_active(User.objects.get(username="adrian")) 34 | assert not is_active(SwappedUser()) 35 | 36 | def test_is_group_member(self): 37 | p1 = is_group_member("somegroup") 38 | assert p1.name == "is_group_member:somegroup" 39 | assert p1.num_args == 1 40 | 41 | p2 = is_group_member("g1", "g2", "g3", "g4") 42 | assert p2.name == "is_group_member:g1,g2,g3,..." 43 | 44 | p = is_group_member("editors") 45 | assert p(User.objects.get(username="martin")) 46 | assert not p(SwappedUser()) 47 | 48 | p = is_group_member("editors", "staff") 49 | assert not p(User.objects.get(username="martin")) 50 | assert not p(SwappedUser()) 51 | -------------------------------------------------------------------------------- /tests/testsuite/contrib/test_rest_framework.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.contrib.auth.models import AnonymousUser 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.test import TestCase 6 | 7 | from rest_framework.decorators import action 8 | from rest_framework.response import Response 9 | from rest_framework.serializers import ModelSerializer 10 | from rest_framework.test import APIRequestFactory 11 | from rest_framework.viewsets import ModelViewSet 12 | 13 | import rules # noqa 14 | from rules.contrib.rest_framework import AutoPermissionViewSetMixin 15 | 16 | 17 | class AutoPermissionRequiredMixinTests(TestCase): 18 | def setUp(self): 19 | from testapp.models import TestModel 20 | 21 | class TestModelSerializer(ModelSerializer): 22 | class Meta: 23 | model = TestModel 24 | fields = "__all__" 25 | 26 | class TestViewSet(AutoPermissionViewSetMixin, ModelViewSet): 27 | queryset = TestModel.objects.all() 28 | serializer_class = TestModelSerializer 29 | permission_type_map = AutoPermissionViewSetMixin.permission_type_map.copy() 30 | permission_type_map["custom_detail"] = "add" 31 | permission_type_map["custom_nodetail"] = "add" 32 | 33 | @action(detail=True) 34 | def custom_detail(self, request): 35 | return Response() 36 | 37 | @action(detail=False) 38 | def custom_nodetail(self, request): 39 | return Response() 40 | 41 | @action(detail=False) 42 | def unknown(self, request): 43 | return Response() 44 | 45 | self.model = TestModel 46 | self.vs = TestViewSet 47 | self.req = APIRequestFactory().get("/") 48 | self.req.user = AnonymousUser() 49 | 50 | def test_predefined_action(self): 51 | # Create should be allowed due to the add permission set on TestModel 52 | self.assertEqual(self.vs.as_view({"get": "create"})(self.req).status_code, 201) 53 | # List should be allowed due to None in permission_type_map 54 | self.assertEqual( 55 | self.vs.as_view({"get": "list"})(self.req, pk=1).status_code, 200 56 | ) 57 | # Retrieve should be allowed due to the view permission set on TestModel 58 | self.assertEqual( 59 | self.vs.as_view({"get": "retrieve"})(self.req, pk=1).status_code, 200 60 | ) 61 | # Destroy should be forbidden due to missing delete permission 62 | self.assertEqual( 63 | self.vs.as_view({"get": "destroy"})(self.req, pk=1).status_code, 403 64 | ) 65 | 66 | def test_custom_actions(self): 67 | # Both should not produce 403 due to being mapped to the add permission 68 | self.assertEqual( 69 | self.vs.as_view({"get": "custom_detail"})(self.req, pk=1).status_code, 404 70 | ) 71 | self.assertEqual( 72 | self.vs.as_view({"get": "custom_nodetail"})(self.req).status_code, 200 73 | ) 74 | 75 | def test_unknown_action(self): 76 | with self.assertRaises(ImproperlyConfigured): 77 | self.vs.as_view({"get": "unknown"})(self.req) 78 | -------------------------------------------------------------------------------- /tests/testsuite/contrib/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.template import Context, Template 3 | from django.test import TestCase 4 | 5 | from testapp.models import Book 6 | 7 | from . import ISBN, TestData 8 | 9 | 10 | class TemplateTagTests(TestData, TestCase): 11 | tpl_format = """{{% spaceless %}} 12 | {{% load rules %}} 13 | {{% {tag} "{name}" user book as can_update %}} 14 | {{% if can_update %}} 15 | OK 16 | {{% else %}} 17 | NOT OK 18 | {{% endif %}} 19 | {{% endspaceless %}}""" 20 | 21 | def test_rule_tag(self): 22 | 23 | # change_book rule 24 | 25 | tpl = Template(self.tpl_format.format(tag="test_rule", name="change_book")) 26 | 27 | # adrian can change his book as its author 28 | html = tpl.render( 29 | Context( 30 | { 31 | "user": User.objects.get(username="adrian"), 32 | "book": Book.objects.get(isbn=ISBN), 33 | } 34 | ) 35 | ) 36 | self.assertEqual(html, "OK") 37 | 38 | # martin can change adrian's book as an editor 39 | html = tpl.render( 40 | Context( 41 | { 42 | "user": User.objects.get(username="martin"), 43 | "book": Book.objects.get(isbn=ISBN), 44 | } 45 | ) 46 | ) 47 | self.assertEqual(html, "OK") 48 | 49 | # delete_book rule 50 | 51 | tpl = Template(self.tpl_format.format(tag="test_rule", name="delete_book")) 52 | 53 | # adrian can delete his book as its author 54 | html = tpl.render( 55 | Context( 56 | { 57 | "user": User.objects.get(username="adrian"), 58 | "book": Book.objects.get(isbn=ISBN), 59 | } 60 | ) 61 | ) 62 | self.assertEqual(html, "OK") 63 | 64 | # martin can *not* delete adrian's book 65 | html = tpl.render( 66 | Context( 67 | { 68 | "user": User.objects.get(username="martin"), 69 | "book": Book.objects.get(isbn=ISBN), 70 | } 71 | ) 72 | ) 73 | self.assertEqual(html, "NOT OK") 74 | 75 | def test_perm_tag(self): 76 | 77 | # change_book permission 78 | 79 | tpl = Template( 80 | self.tpl_format.format(tag="has_perm", name="testapp.change_book") 81 | ) 82 | 83 | # adrian can change his book as its author 84 | html = tpl.render( 85 | Context( 86 | { 87 | "user": User.objects.get(username="adrian"), 88 | "book": Book.objects.get(isbn=ISBN), 89 | } 90 | ) 91 | ) 92 | self.assertEqual(html, "OK") 93 | 94 | # martin can change adrian's book as an editor 95 | html = tpl.render( 96 | Context( 97 | { 98 | "user": User.objects.get(username="martin"), 99 | "book": Book.objects.get(isbn=ISBN), 100 | } 101 | ) 102 | ) 103 | self.assertEqual(html, "OK") 104 | 105 | # delete_book permission 106 | 107 | tpl = Template( 108 | self.tpl_format.format(tag="has_perm", name="testapp.delete_book") 109 | ) 110 | 111 | # adrian can delete his book as its author 112 | html = tpl.render( 113 | Context( 114 | { 115 | "user": User.objects.get(username="adrian"), 116 | "book": Book.objects.get(isbn=ISBN), 117 | } 118 | ) 119 | ) 120 | self.assertEqual(html, "OK") 121 | 122 | # martin can *not* delete adrian's book 123 | html = tpl.render( 124 | Context( 125 | { 126 | "user": User.objects.get(username="martin"), 127 | "book": Book.objects.get(isbn=ISBN), 128 | } 129 | ) 130 | ) 131 | self.assertEqual(html, "NOT OK") 132 | -------------------------------------------------------------------------------- /tests/testsuite/contrib/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.contrib.auth.models import AnonymousUser 4 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied 5 | from django.http import Http404, HttpRequest 6 | from django.test import RequestFactory, TestCase 7 | from django.urls import reverse 8 | from django.utils.encoding import force_str 9 | from django.views.generic import CreateView, View 10 | 11 | from testapp.models import Book 12 | 13 | import rules # noqa 14 | from rules.contrib.views import AutoPermissionRequiredMixin, objectgetter 15 | 16 | from . import TestData 17 | 18 | 19 | class FBVDecoratorTests(TestData, TestCase): 20 | def test_objectgetter(self): 21 | request = HttpRequest() 22 | book = Book.objects.get(pk=1) 23 | 24 | self.assertEqual(book, objectgetter(Book)(request, pk=1)) 25 | self.assertEqual(book, objectgetter(Book, attr_name="id")(request, id=1)) 26 | self.assertEqual(book, objectgetter(Book, field_name="id")(request, pk=1)) 27 | 28 | with self.assertRaises(ImproperlyConfigured): 29 | # Raise if no `pk` argument is provided to the view 30 | self.assertEqual(book, objectgetter(Book)(request, foo=1)) 31 | 32 | with self.assertRaises(ImproperlyConfigured): 33 | # Raise if given invalid model lookup field 34 | self.assertEqual(book, objectgetter(Book, field_name="foo")(request, pk=1)) 35 | 36 | with self.assertRaises(Http404): 37 | # Raise 404 if no model instance found 38 | self.assertEqual(book, objectgetter(Book)(request, pk=100000)) 39 | 40 | def test_permission_required(self): 41 | # Adrian can change his book 42 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 43 | response = self.client.get(reverse("change_book", args=(1,))) 44 | self.assertEqual(response.status_code, 200) 45 | self.assertEqual(force_str(response.content).strip(), "OK") 46 | 47 | # Martin can change Adrian's book 48 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 49 | response = self.client.get(reverse("change_book", args=(1,))) 50 | self.assertEqual(response.status_code, 200) 51 | self.assertEqual(force_str(response.content).strip(), "OK") 52 | 53 | # Adrian can delete his book 54 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 55 | response = self.client.get(reverse("delete_book", args=(1,))) 56 | self.assertEqual(response.status_code, 200) 57 | self.assertEqual(force_str(response.content).strip(), "OK") 58 | 59 | # Martin can *not* create a book 60 | # Up to Django v2.1, the response was a redirect to login 61 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 62 | response = self.client.get(reverse("cbv.create_book")) 63 | self.assertIn(response.status_code, [302, 403]) 64 | 65 | # Martin can *not* delete Adrian's book and is redirected to login 66 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 67 | response = self.client.get(reverse("delete_book", args=(1,))) 68 | self.assertEqual(response.status_code, 302) 69 | 70 | # Martin can *not* delete Adrian's book and an PermissionDenied is raised 71 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 72 | response = self.client.get(reverse("view_that_raises", args=(1,))) 73 | self.assertEqual(response.status_code, 403) 74 | 75 | # Test views that require a list of permissions 76 | 77 | # Adrian has both permissions 78 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 79 | response = self.client.get(reverse("view_with_permission_list", args=(1,))) 80 | self.assertEqual(response.status_code, 200) 81 | self.assertEqual(force_str(response.content).strip(), "OK") 82 | 83 | # Martin does not have delete permission 84 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 85 | response = self.client.get(reverse("view_with_permission_list", args=(1,))) 86 | self.assertEqual(response.status_code, 302) 87 | 88 | # Test views that accept a static object as argument 89 | # fn is passed to has_perm as-is 90 | 91 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 92 | response = self.client.get(reverse("view_with_object", args=(1,))) 93 | self.assertEqual(response.status_code, 200) 94 | self.assertEqual(force_str(response.content).strip(), "OK") 95 | 96 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 97 | response = self.client.get(reverse("view_with_object", args=(1,))) 98 | self.assertEqual(response.status_code, 302) 99 | 100 | 101 | class CBVMixinTests(TestData, TestCase): 102 | def test_get_object_error(self): 103 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 104 | with self.assertRaises(AttributeError): 105 | self.client.get(reverse("cbv.change_book_error", args=(1,))) 106 | 107 | def test_permission_required_mixin(self): 108 | # Adrian can change his book 109 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 110 | response = self.client.get(reverse("cbv.change_book", args=(1,))) 111 | self.assertEqual(response.status_code, 200) 112 | self.assertEqual(force_str(response.content).strip(), "OK") 113 | 114 | # Martin can change Adrian's book 115 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 116 | response = self.client.get(reverse("cbv.change_book", args=(1,))) 117 | self.assertEqual(response.status_code, 200) 118 | self.assertEqual(force_str(response.content).strip(), "OK") 119 | 120 | # Adrian can delete his book 121 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 122 | response = self.client.get(reverse("cbv.delete_book", args=(1,))) 123 | self.assertEqual(response.status_code, 200) 124 | self.assertEqual(force_str(response.content).strip(), "OK") 125 | 126 | # Martin can *not* delete Adrian's book 127 | # Up to Django v2.1, the response was a redirect to login 128 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 129 | response = self.client.get(reverse("cbv.delete_book", args=(1,))) 130 | self.assertIn(response.status_code, [302, 403]) 131 | 132 | # Martin can *not* delete Adrian's book and an PermissionDenied is raised 133 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 134 | response = self.client.get(reverse("cbv.view_that_raises", args=(1,))) 135 | self.assertEqual(response.status_code, 403) 136 | 137 | # Test views that require a list of permissions 138 | 139 | # Adrian has both permissions 140 | self.assertTrue(self.client.login(username="adrian", password="secr3t")) 141 | response = self.client.get(reverse("cbv.view_with_permission_list", args=(1,))) 142 | self.assertEqual(response.status_code, 200) 143 | self.assertEqual(force_str(response.content).strip(), "OK") 144 | 145 | # Martin does not have delete permission 146 | # Up to Django v2.1, the response was a redirect to login 147 | self.assertTrue(self.client.login(username="martin", password="secr3t")) 148 | response = self.client.get(reverse("cbv.view_with_permission_list", args=(1,))) 149 | self.assertIn(response.status_code, [302, 403]) 150 | 151 | 152 | class AutoPermissionRequiredMixinTests(TestCase): 153 | def setUp(self): 154 | from testapp.models import TestModel 155 | 156 | self.model = TestModel 157 | self.req = RequestFactory().get("/") 158 | self.req.user = AnonymousUser() 159 | 160 | def test_predefined_view_type(self): 161 | class TestView(AutoPermissionRequiredMixin, CreateView): 162 | model = self.model 163 | fields = () 164 | 165 | self.assertEqual(TestView.as_view()(self.req).status_code, 200) 166 | 167 | def test_custom_view_type(self): 168 | class CustomViewMixin: 169 | pass 170 | 171 | class TestView(AutoPermissionRequiredMixin, CustomViewMixin, CreateView): 172 | model = self.model 173 | fields = () 174 | permission_type_map = [ 175 | (CustomViewMixin, "unknown_perm") 176 | ] + AutoPermissionRequiredMixin.permission_type_map 177 | raise_exception = True 178 | 179 | with self.assertRaises(PermissionDenied): 180 | TestView.as_view()(self.req) 181 | 182 | def test_unknown_view_type(self): 183 | class TestView(AutoPermissionRequiredMixin, View): 184 | pass 185 | 186 | with self.assertRaises(ImproperlyConfigured): 187 | TestView.as_view()(self.req) 188 | 189 | def test_overwrite_perm_type(self): 190 | class TestView(AutoPermissionRequiredMixin, CreateView): 191 | model = self.model 192 | fields = () 193 | permission_type = "unknown" 194 | raise_exception = True 195 | 196 | with self.assertRaises(PermissionDenied): 197 | TestView.as_view()(self.req) 198 | 199 | def test_disable_perm_checking(self): 200 | class TestView(AutoPermissionRequiredMixin, CreateView): 201 | model = self.model 202 | fields = () 203 | permission_type = None 204 | 205 | self.assertEqual(TestView.as_view()(self.req).status_code, 200) 206 | 207 | def test_permission_required_passthrough(self): 208 | class TestView(AutoPermissionRequiredMixin, CreateView): 209 | model = self.model 210 | fields = () 211 | permission_required = "testapp.unknown_perm" 212 | raise_exception = True 213 | 214 | with self.assertRaises(PermissionDenied): 215 | TestView.as_view()(self.req) 216 | -------------------------------------------------------------------------------- /tests/testsuite/test_permissions.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from rules.permissions import ( 4 | ObjectPermissionBackend, 5 | add_perm, 6 | has_perm, 7 | perm_exists, 8 | permissions, 9 | remove_perm, 10 | set_perm, 11 | ) 12 | from rules.predicates import always_false, always_true 13 | 14 | 15 | class PermissionsTests(TestCase): 16 | @staticmethod 17 | def reset_ruleset(ruleset): 18 | for k in list(ruleset.keys()): 19 | ruleset.pop(k) 20 | 21 | def setUp(self): 22 | self.reset_ruleset(permissions) 23 | 24 | def tearDown(self): 25 | self.reset_ruleset(permissions) 26 | 27 | def test_permissions_ruleset(self): 28 | add_perm("can_edit_book", always_true) 29 | assert "can_edit_book" in permissions 30 | assert perm_exists("can_edit_book") 31 | assert has_perm("can_edit_book") 32 | with self.assertRaises(KeyError): 33 | add_perm("can_edit_book", always_false) 34 | set_perm("can_edit_book", always_false) 35 | assert not has_perm("can_edit_book") 36 | remove_perm("can_edit_book") 37 | assert not perm_exists("can_edit_book") 38 | 39 | def test_backend(self): 40 | backend = ObjectPermissionBackend() 41 | assert backend.authenticate("someuser", "password") is None 42 | 43 | add_perm("can_edit_book", always_true) 44 | assert "can_edit_book" in permissions 45 | assert backend.has_perm(None, "can_edit_book") 46 | assert backend.has_module_perms(None, "can_edit_book") 47 | with self.assertRaises(KeyError): 48 | add_perm("can_edit_book", always_true) 49 | set_perm("can_edit_book", always_false) 50 | assert not backend.has_perm(None, "can_edit_book") 51 | remove_perm("can_edit_book") 52 | assert not perm_exists("can_edit_book") 53 | -------------------------------------------------------------------------------- /tests/testsuite/test_predicates.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from unittest import TestCase 3 | 4 | from rules.predicates import ( 5 | NO_VALUE, 6 | Predicate, 7 | always_allow, 8 | always_deny, 9 | always_false, 10 | always_true, 11 | predicate, 12 | ) 13 | 14 | 15 | class PredicateKwonlyTests(TestCase): 16 | def test_predicate_kwargonly(self): 17 | def p(foo, *, bar): 18 | return True 19 | 20 | with self.assertRaises(TypeError): 21 | predicate(p) 22 | 23 | def p2(foo, *a, bar): 24 | return True 25 | 26 | with self.assertRaises(TypeError): 27 | predicate(p2) 28 | 29 | def p3(foo, *, bar="bar"): 30 | return True 31 | 32 | # Should not fail 33 | predicate(p3) 34 | 35 | 36 | class PredicateTests(TestCase): 37 | def test_always_true(self): 38 | assert always_true() 39 | 40 | def test_always_false(self): 41 | assert not always_false() 42 | 43 | def test_always_allow(self): 44 | assert always_allow() 45 | 46 | def test_always_deny(self): 47 | assert not always_deny() 48 | 49 | def test_lambda_predicate(self): 50 | p = Predicate(lambda x: x == "a") 51 | assert p.name == "" 52 | assert p.num_args == 1 53 | assert p("a") 54 | 55 | def test_lambda_predicate_custom_name(self): 56 | p = Predicate(lambda x: x == "a", name="mypred") 57 | assert p.name == "mypred" 58 | assert p.num_args == 1 59 | assert p("a") 60 | 61 | def test_function_predicate(self): 62 | def mypred(x): 63 | return x == "a" 64 | 65 | p = Predicate(mypred) 66 | assert p.name == "mypred" 67 | assert p.num_args == 1 68 | assert p("a") 69 | 70 | def test_function_predicate_custom_name(self): 71 | def mypred(x): 72 | return x == "a" 73 | 74 | p = Predicate(mypred, name="foo") 75 | assert p.name == "foo" 76 | assert p.num_args == 1 77 | assert p("a") 78 | 79 | def test_partial_function_predicate(self): 80 | def mypred(one, two, three): 81 | return one < two < three 82 | 83 | p = Predicate(functools.partial(mypred, 1)) 84 | assert p.name == "mypred" 85 | assert p.num_args == 2 # 3 - 1 partial 86 | assert p(2, 3) 87 | p = Predicate(functools.partial(mypred, 1, 2)) 88 | assert p.name == "mypred" 89 | assert p.num_args == 1 # 3 - 2 partial 90 | assert p(3) 91 | 92 | def test_method_predicate(self): 93 | class SomeClass(object): 94 | def some_method(self, arg1, arg2): 95 | return arg1 == arg2 96 | 97 | obj = SomeClass() 98 | p = Predicate(obj.some_method) 99 | assert p.name == "some_method" 100 | assert p.num_args == 2 101 | assert p(2, 2) 102 | 103 | def test_partial_method_predicate(self): 104 | class SomeClass(object): 105 | def some_method(self, arg1, arg2): 106 | return arg1 == arg2 107 | 108 | obj = SomeClass() 109 | p = Predicate(functools.partial(obj.some_method, 2)) 110 | assert p.name == "some_method" 111 | assert p.num_args == 1 112 | assert p(2) 113 | 114 | def test_class_predicate(self): 115 | class callableclass(object): 116 | def __call__(self, arg1, arg2): 117 | return arg1 == arg2 118 | 119 | fn = callableclass() 120 | p = Predicate(fn) 121 | assert p.name == "callableclass" 122 | assert p.num_args == 2 123 | assert p("a", "a") 124 | 125 | def test_class_predicate_custom_name(self): 126 | class callableclass(object): 127 | def __call__(self, arg): 128 | return arg == "a" 129 | 130 | fn = callableclass() 131 | p = Predicate(fn, name="bar") 132 | assert p.name == "bar" 133 | assert p.num_args == 1 134 | assert p("a") 135 | 136 | def test_predicate_predicate(self): 137 | def mypred(x): 138 | return x == "a" 139 | 140 | p = Predicate(Predicate(mypred)) 141 | assert p.name == "mypred" 142 | assert p.num_args == 1 143 | assert p("a") 144 | 145 | def test_predicate_predicate_custom_name(self): 146 | def mypred(x): 147 | return x == "a" 148 | 149 | p = Predicate(Predicate(mypred, name="foo")) 150 | assert p.name == "foo" 151 | assert p.num_args == 1 152 | assert p("a") 153 | 154 | def test_predicate_bind(self): 155 | @predicate(bind=True) 156 | def is_bound(self): 157 | return self is is_bound 158 | 159 | assert is_bound() 160 | 161 | p = None 162 | 163 | def mypred(self): 164 | return self is p 165 | 166 | p = Predicate(mypred, bind=True) 167 | assert p() 168 | 169 | def test_decorator(self): 170 | @predicate 171 | def mypred(arg1, arg2): 172 | return True 173 | 174 | assert mypred.name == "mypred" 175 | assert mypred.num_args == 2 176 | 177 | def test_decorator_noargs(self): 178 | @predicate() 179 | def mypred(arg1, arg2): 180 | return True 181 | 182 | assert mypred.name == "mypred" 183 | assert mypred.num_args == 2 184 | 185 | def test_decorator_custom_name(self): 186 | @predicate("foo") 187 | def mypred(): 188 | return True 189 | 190 | assert mypred.name == "foo" 191 | assert mypred.num_args == 0 192 | 193 | @predicate(name="bar") 194 | def myotherpred(): 195 | return False 196 | 197 | assert myotherpred.name == "bar" 198 | assert myotherpred.num_args == 0 199 | 200 | def test_repr(self): 201 | @predicate 202 | def mypred(arg1, arg2): 203 | return True 204 | 205 | assert repr(mypred).startswith(" 0 267 | assert len(kwargs) == 0 268 | 269 | assert p.num_args == 0 270 | p.test("a") 271 | p.test("a", "b") 272 | 273 | def test_no_args(self): 274 | @predicate 275 | def p(*args, **kwargs): 276 | assert len(args) == 0 277 | assert len(kwargs) == 0 278 | 279 | assert p.num_args == 0 280 | p.test() 281 | 282 | def test_one_arg(self): 283 | @predicate 284 | def p(a=None, *args, **kwargs): 285 | assert len(args) == 0 286 | assert len(kwargs) == 0 287 | assert a == "a" 288 | 289 | assert p.num_args == 1 290 | p.test("a") 291 | 292 | def test_two_args(self): 293 | @predicate 294 | def p(a=None, b=None, *args, **kwargs): 295 | assert len(args) == 0 296 | assert len(kwargs) == 0 297 | assert a == "a" 298 | assert b == "b" 299 | 300 | assert p.num_args == 2 301 | p.test("a", "b") 302 | 303 | def test_no_mask(self): 304 | @predicate 305 | def p(a=None, b=None, *args, **kwargs): 306 | assert len(args) == 0 307 | assert len(kwargs) == 1 308 | assert "c" in kwargs 309 | assert a == "a" 310 | assert b == "b" 311 | 312 | p("a", b="b", c="c") 313 | 314 | def test_no_value_marker(self): 315 | @predicate 316 | def p(a, b=None): 317 | assert a == "a" 318 | assert b is None 319 | 320 | assert not NO_VALUE 321 | p.test("a") 322 | p.test("a", NO_VALUE) 323 | 324 | def test_short_circuit(self): 325 | @predicate 326 | def skipped_predicate(self): 327 | return None 328 | 329 | @predicate 330 | def shorted_predicate(self): 331 | raise Exception("this predicate should not be evaluated") 332 | 333 | assert (always_false & shorted_predicate).test() is False 334 | assert (always_true | shorted_predicate).test() is True 335 | 336 | def raises(pred): 337 | try: 338 | pred.test() 339 | return False 340 | except Exception as e: 341 | return "evaluated" in str(e) 342 | 343 | assert raises(always_true & shorted_predicate) 344 | assert raises(always_false | shorted_predicate) 345 | assert raises(skipped_predicate & shorted_predicate) 346 | assert raises(skipped_predicate | shorted_predicate) 347 | 348 | def test_skip_predicate(self): 349 | @predicate(bind=True) 350 | def requires_two_args(self, a, b): 351 | return a == b if len(self.context.args) > 1 else None 352 | 353 | @predicate 354 | def passthrough(a): 355 | return a 356 | 357 | assert (requires_two_args & passthrough).test(True, True) is True 358 | assert (requires_two_args & passthrough).test(True, False) is False 359 | 360 | # because requires_two_args is called with only one argument 361 | # its result is not taken into account, only the result of the 362 | # other predicate matters. 363 | assert (requires_two_args & passthrough).test(True) is True 364 | assert (requires_two_args & passthrough).test(False) is False 365 | assert (requires_two_args | passthrough).test(True) is True 366 | assert (requires_two_args | passthrough).test(False) is False 367 | 368 | # test that order does not matter 369 | assert (passthrough & requires_two_args).test(True) is True 370 | assert (passthrough & requires_two_args).test(False) is False 371 | assert (passthrough | requires_two_args).test(True) is True 372 | assert (passthrough | requires_two_args).test(False) is False 373 | 374 | # test that inversion does not modify the result 375 | assert (~requires_two_args & passthrough).test(True) is True 376 | assert (~requires_two_args & passthrough).test(False) is False 377 | assert (~requires_two_args | passthrough).test(True) is True 378 | assert (~requires_two_args | passthrough).test(False) is False 379 | assert (passthrough & ~requires_two_args).test(True) is True 380 | assert (passthrough & ~requires_two_args).test(False) is False 381 | assert (passthrough | ~requires_two_args).test(True) is True 382 | assert (passthrough | ~requires_two_args).test(False) is False 383 | 384 | # test that when all predicates are skipped, result is False 385 | assert requires_two_args.test(True) is False 386 | assert (requires_two_args | requires_two_args).test(True) is False 387 | assert (requires_two_args & requires_two_args).test(True) is False 388 | 389 | # test that a skipped predicate doesn't alter the result at all 390 | assert (requires_two_args | requires_two_args | passthrough).test(True) is True 391 | assert (requires_two_args & requires_two_args & passthrough).test(True) is True 392 | 393 | def test_invocation_context(self): 394 | @predicate 395 | def p1(): 396 | assert id(p1.context) == id(p2.context) 397 | assert p1.context.args == ("a",) 398 | return True 399 | 400 | @predicate 401 | def p2(): 402 | assert id(p1.context) == id(p2.context) 403 | assert p2.context.args == ("a",) 404 | return True 405 | 406 | p = p1 & p2 407 | assert p.test("a") 408 | assert p.context is None 409 | 410 | def test_invocation_context_nested(self): 411 | @predicate 412 | def p1(): 413 | assert p1.context.args == ("b1",) 414 | return True 415 | 416 | @predicate 417 | def p2(): 418 | assert p2.context.args == ("b2",) 419 | return True 420 | 421 | @predicate 422 | def p(): 423 | assert p1.context.args == ("a",) 424 | return p1.test("b1") & p2.test("b2") 425 | 426 | assert p.test("a") 427 | assert p.context is None 428 | 429 | def test_invocation_context_storage(self): 430 | @predicate 431 | def p1(a): 432 | p1.context["p1.a"] = a 433 | return True 434 | 435 | @predicate 436 | def p2(a): 437 | return p2.context["p1.a"] == a 438 | 439 | p = p1 & p2 440 | assert p.test("a") 441 | -------------------------------------------------------------------------------- /tests/testsuite/test_rulesets.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from rules.predicates import always_false, always_true 4 | from rules.rulesets import ( 5 | RuleSet, 6 | add_rule, 7 | default_rules, 8 | remove_rule, 9 | rule_exists, 10 | set_rule, 11 | test_rule, 12 | ) 13 | 14 | 15 | class RulesetTests(TestCase): 16 | @staticmethod 17 | def reset_ruleset(ruleset): 18 | for k in list(ruleset.keys()): 19 | ruleset.pop(k) 20 | 21 | def setUp(self): 22 | self.reset_ruleset(default_rules) 23 | 24 | def tearDown(self): 25 | self.reset_ruleset(default_rules) 26 | 27 | def test_shared_ruleset(self): 28 | add_rule("somerule", always_true) 29 | assert "somerule" in default_rules 30 | assert rule_exists("somerule") 31 | assert test_rule("somerule") 32 | assert test_rule("somerule") 33 | with self.assertRaises(KeyError): 34 | add_rule("somerule", always_false) 35 | set_rule("somerule", always_false) 36 | assert not test_rule("somerule") 37 | remove_rule("somerule") 38 | assert not rule_exists("somerule") 39 | 40 | def test_ruleset(self): 41 | ruleset = RuleSet() 42 | ruleset.add_rule("somerule", always_true) 43 | assert "somerule" in ruleset 44 | assert ruleset.rule_exists("somerule") 45 | assert ruleset.test_rule("somerule") 46 | with self.assertRaises(KeyError): 47 | ruleset.add_rule("somerule", always_true) 48 | ruleset.set_rule("somerule", always_false) 49 | assert not test_rule("somerule") 50 | ruleset.remove_rule("somerule") 51 | assert not ruleset.rule_exists("somerule") 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311,312,py3}-dj{32,42} 4 | py{310,311,312,py3}-dj{50,51} 5 | py312-packaging 6 | 7 | [gh-actions] 8 | python = 9 | 3.8: py38 10 | 3.9: py39 11 | 3.10: py310 12 | 3.11: py311 13 | 3.12: py312 14 | pypy-3.10: pypy3 15 | 16 | [gh-actions:env] 17 | DJANGO = 18 | 3.2: dj32 19 | 4.2: dj42 20 | 5.0: dj50 21 | 5.1: dj51 22 | main: djmain 23 | packaging: packaging 24 | 25 | [testenv] 26 | usedevelop = true 27 | deps = 28 | coverage 29 | djangorestframework 30 | dj32: Django~=3.2.0 31 | dj42: Django~=4.2.0 32 | dj50: Django~=5.0.0 33 | dj51: Django~=5.1.0 34 | commands = 35 | py{38,39,310,311,312}: coverage run tests/manage.py test testsuite {posargs: -v 2} 36 | py{38,39,310,311,312}: coverage report -m 37 | pypy3: {envpython} tests/manage.py test testsuite {posargs: -v 2} 38 | 39 | [testenv:py312-packaging] 40 | usedevelop = false 41 | deps = 42 | django 43 | djangorestframework 44 | commands = 45 | {envpython} tests/manage.py test testsuite {posargs: -v 2} 46 | 47 | [testenv:py312-djmain] 48 | deps = 49 | https://github.com/django/django/archive/main.tar.gz#egg=django 50 | djangorestframework 51 | commands = 52 | {envpython} tests/manage.py test testsuite {posargs: -v 2} 53 | --------------------------------------------------------------------------------