├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── ci.yml │ └── publish_to_pypi.yml ├── .gitignore ├── .pyproject.toml ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── admin_site.rst ├── changelog.rst ├── conf.py ├── custom_processors.rst ├── example.rst ├── img │ ├── admin_groups.png │ ├── admin_permissions.png │ ├── admin_section.png │ └── admin_views.png ├── index.rst ├── installation.rst ├── make.bat ├── management_commands.rst ├── overview.rst ├── processors │ ├── auth_decorators.rst │ ├── auth_mixins.rst │ └── index.rst ├── requirements.txt └── settings.rst ├── example ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── test_app │ ├── __init__.py │ ├── admin.py │ ├── templates │ ├── base.html │ ├── home.html │ ├── permissions_list.html │ └── users.html │ ├── urls.py │ └── views │ ├── __init__.py │ ├── cbv_based.py │ └── function_based.py ├── permissions_auditor ├── __init__.py ├── admin.py ├── apps.py ├── core.py ├── defaults.py ├── forms.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── check_view_permissions.py │ │ └── dump_view_permissions.py ├── processors │ ├── __init__.py │ ├── auth_decorators.py │ ├── auth_mixins.py │ └── base.py ├── templates │ └── permissions_auditor │ │ └── admin │ │ ├── permission_detail.html │ │ └── views_index.html └── tests │ ├── __init__.py │ ├── base.py │ ├── fixtures │ ├── __init__.py │ ├── base_views.py │ ├── decorator_views.py │ ├── mixin_views.py │ ├── urls.py │ └── views.py │ ├── test_auth_decorator_processors.py │ ├── test_auth_mixin_processors.py │ ├── test_base_processors.py │ ├── test_core.py │ ├── test_management.py │ └── test_settings.py ├── runtests.py ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | source = 4 | permissions_auditor 5 | 6 | branch = True 7 | 8 | omit = 9 | permissions_auditor/defaults.py 10 | */migrations/* 11 | */tests/* 12 | */admin.py 13 | */urls.py 14 | 15 | [report] 16 | # Regexes for lines to exclude from consideration 17 | exclude_lines = 18 | # Have to re-enable the standard pragma 19 | pragma: no cover 20 | 21 | # Don't complain about missing debug-only code: 22 | def __repr__ 23 | if self\.debug 24 | 25 | # Don't complain if tests don't hit defensive assertion code: 26 | raise AssertionError 27 | raise NotImplementedError 28 | 29 | # Don't complain if non-runnable code isn't run: 30 | if 0: 31 | if __name__ == .__main__.: 32 | 33 | ignore_errors = True 34 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,*/__pycache__/*,*/migrations/*,settings*.py,docs/*,*.tox/* 3 | max-line-length = 100 4 | 5 | # E12x continuation line indentation 6 | # E251 no spaces around keyword / parameter equals 7 | # E261 at least two spaces before inline comment 8 | # E731 do not assign a lambda expression, use a def 9 | # W504 line break after binary operator 10 | ignore = E12,E251,E261,E731,W504 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | python: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 5 10 | matrix: 11 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Get pip cache dir 22 | id: pip-cache 23 | run: | 24 | echo "::set-output name=dir::$(pip cache dir)" 25 | 26 | - name: Cache 27 | uses: actions/cache@v2 28 | with: 29 | path: ${{ steps.pip-cache.outputs.dir }} 30 | key: 31 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} 32 | restore-keys: | 33 | ${{ matrix.python-version }}-v1- 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install tox tox-gh-actions 39 | 40 | - name: Test 41 | run: tox 42 | 43 | - name: Upload coverage 44 | uses: codecov/codecov-action@v1 45 | with: 46 | name: Python ${{ matrix.python-version }} 47 | 48 | lint: 49 | runs-on: ubuntu-latest 50 | 51 | steps: 52 | - uses: actions/checkout@v2 53 | 54 | - name: Set up Python ${{ matrix.python-version }} 55 | uses: actions/setup-python@v2 56 | with: 57 | python-version: '3.12' 58 | 59 | - name: Get pip cache dir 60 | id: pip-cache 61 | run: | 62 | echo "::set-output name=dir::$(pip cache dir)" 63 | 64 | - name: Cache 65 | uses: actions/cache@v2 66 | with: 67 | path: ${{ steps.pip-cache.outputs.dir }} 68 | key: 69 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} 70 | restore-keys: | 71 | ${{ matrix.python-version }}-v1- 72 | 73 | - name: Install dependencies 74 | run: | 75 | python -m pip install --upgrade pip 76 | python -m pip install --upgrade tox 77 | - name: Run flake8 78 | run: tox -e lint 79 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: >- 32 | Publish Python 🐍 distribution 📦 to PyPI 33 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 34 | needs: 35 | - build 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/django-permissions-auditor 40 | permissions: 41 | id-token: write # IMPORTANT: mandatory for trusted publishing 42 | 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v3 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Publish distribution 📦 to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | 52 | github-release: 53 | name: >- 54 | Sign the Python 🐍 distribution 📦 with Sigstore 55 | and upload them to GitHub Release 56 | needs: 57 | - publish-to-pypi 58 | runs-on: ubuntu-latest 59 | 60 | permissions: 61 | contents: write # IMPORTANT: mandatory for making GitHub Releases 62 | id-token: write # IMPORTANT: mandatory for sigstore 63 | 64 | steps: 65 | - name: Download all the dists 66 | uses: actions/download-artifact@v3 67 | with: 68 | name: python-package-distributions 69 | path: dist/ 70 | - name: Sign the dists with Sigstore 71 | uses: sigstore/gh-action-sigstore-python@v1.2.3 72 | with: 73 | inputs: >- 74 | ./dist/*.tar.gz 75 | ./dist/*.whl 76 | - name: Create GitHub Release 77 | env: 78 | GITHUB_TOKEN: ${{ github.token }} 79 | run: >- 80 | gh release create 81 | '${{ github.ref_name }}' 82 | --repo '${{ github.repository }}' 83 | --notes "" 84 | - name: Upload artifact signatures to GitHub Release 85 | env: 86 | GITHUB_TOKEN: ${{ github.token }} 87 | # Upload to GitHub Release using the `gh` CLI. 88 | # `dist/` contains the built packages, and the 89 | # sigstore-produced signatures and certificates. 90 | run: >- 91 | gh release upload 92 | '${{ github.ref_name }}' dist/** 93 | --repo '${{ github.repository }}' 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .AppleDouble 3 | .LSOverride 4 | 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | .Python 9 | env/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | docs/_* 25 | htmlcov/ 26 | .tox/ 27 | .coverage 28 | .cache 29 | nosetests.xml 30 | coverage.xml 31 | *.sqlite3 32 | -------------------------------------------------------------------------------- /.pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: ubuntu-20.04 8 | tools: 9 | python: "3.11" 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | - method: setuptools 15 | path: . 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 AAC Engineering 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include permissions_auditor/templates * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-permissions-auditor 2 | ========================== 3 | 4 | ![Build](https://github.com/AACEngineering/django-permissions-auditor/workflows/Test/badge.svg) 5 | [![codecov](https://codecov.io/gh/AACEngineering/django-permissions-auditor/branch/master/graph/badge.svg)](https://codecov.io/gh/AACEngineering/django-permissions-auditor) 6 | [![](https://img.shields.io/pypi/v/django-permissions-auditor.svg)](https://pypi.org/project/django-permissions-auditor/) 7 | [![](https://readthedocs.org/projects/django-permissions-auditor/badge/?version=latest&style=flat)](https://django-permissions-auditor.readthedocs.io/en/latest/) 8 | 9 | 10 | https://django-permissions-auditor.readthedocs.io/en/latest/ 11 | 12 | 13 | Admin site for auditing and managing permissions for views in your Django app. 14 | 15 | 16 | ![Screenshot](docs/img/admin_views.png?raw=true "Screenshot") 17 | 18 | 19 | Automatically parse Django views in your project and pull out the permissions required to view them. Easily extensible to work with custom permission schemes. 20 | 21 | 22 | ![Screenshot](docs/img/admin_permissions.png?raw=true "Screenshot") 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/admin_site.rst: -------------------------------------------------------------------------------- 1 | Admin Site 2 | ============ 3 | 4 | Once installed, you should see a `Permissions Auditor` category in your Django admin panel. 5 | 6 | .. image:: img/admin_section.png 7 | 8 | 9 | .. note:: 10 | All staff members will be able to access the site views index. 11 | 12 | 13 | Site Views 14 | ---------- 15 | 16 | 17 | .. image:: img/admin_views.png 18 | 19 | 20 | Your registered site views should display with the permissions required and any additional information 21 | in the table. 22 | 23 | .. note:: 24 | If you see unexpected results, or missing permissions, ensure your :ref:`processors` are correctly 25 | configured. You may need to create a custom processor if you have a view that does not use 26 | the built-in Django auth mixins / decorators. 27 | 28 | 29 | When you click on a permission, you will be taken to a page which will allow you to manage 30 | what users and groups have that permission. 31 | 32 | 33 | Permissions Management Page 34 | --------------------------- 35 | 36 | Detected permissions will be automatically hyperlinked to a configuration page where you can modify 37 | what groups and users have the permission. 38 | 39 | 40 | .. image:: img/admin_permissions.png 41 | 42 | .. note:: 43 | In order to modify permissions on this page, the user must have the ``auth.change_user`` and 44 | ``auth.change_group`` permissions. 45 | 46 | 47 | Groups Management Page 48 | ---------------------- 49 | 50 | The default Django groups page does not let you quickly see what permissions are assigned to groups 51 | without viewing each group individually. 52 | 53 | .. image:: img/admin_groups.png 54 | 55 | 56 | Django-permissions-auditor overrides the default groups admin list to show the assigned permissions 57 | and active users. This behavior can be disabled via the :ref:`PERMISSIONS_AUDITOR_ADMIN_OVERRIDE_GROUPS` setting. 58 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v1.2.0 (Released 1/9/2024) 5 | -------------------------- 6 | 7 | - Added ``dump_view_permissions`` management command. 8 | - Added Python 3.12 and Django 5.0 to the test matrix. Removed unsupported Python 3.7. 9 | 10 | 11 | v1.1.0 (Released 4/12/2023) 12 | --------------------------- 13 | 14 | - **Potentially breaking change:** Fixed typo on ``BaseFilteredMixinProcessor`` (previously was ``BaseFileredMixinProcessor``). Thanks @annamooseity! 15 | - ``BaseFilteredMixinProcessor`` now inspects all inherited classes instead of only its direct parent. Thanks @annamooseity! 16 | - Added Python 3.11 and Django 4.1 and 4.2 to the test matrix. Removed unsupported Python 3.6. 17 | 18 | 19 | v1.0.5 (Released 1/10/2022) 20 | --------------------------- 21 | 22 | - Added core support for retrieving Django Rest Framework class instances on DRF views. Note that you will still need to write a custom processor to parse these views. Thanks @jeffgabhart! 23 | - Added support for Django 4.0. Fixed various deprecation warnings. 24 | - Added Python 3.10 and Django 4.0 to the test matrix. Removed unsupported Django 3.0 and 3.1 versions. 25 | 26 | 27 | v1.0.4 (Released 5/20/2021) 28 | --------------------------- 29 | 30 | - Changed AuditorGroupAdmin to use the User model's default manager. 31 | - Confirmed support for Django 3.2. 32 | 33 | 34 | v1.0.3 (Released 2/15/2021) 35 | --------------------------- 36 | 37 | - Fixed invalid URL on AuditorGroupAdmin when using a custom user model (#11). Thanks @LerikG. 38 | - Temporarily removed testing on django master. 39 | 40 | 41 | v1.0.2 (Released 1/4/2021) 42 | -------------------------- 43 | 44 | - Changed "No Grouping" filter to order by URL instead of view name. 45 | - Added ``django.views.generic.base.RedirectView`` to the default ``view_names`` blacklist. 46 | - Prevented duplicate permissions from being returned for a single view. 47 | - Dropped testing support for python 3.5, which reached end of life in September 2020. 48 | 49 | 50 | v1.0.1 (Released 7/1/2020) 51 | -------------------------- 52 | 53 | - Fix admin error when looking up malformed permission strings. 54 | 55 | 56 | v1.0.0 (Released 12/4/2019) 57 | --------------------------- 58 | 59 | - Decorator processor improvements. 60 | 61 | Added support for nested decorators: 62 | 63 | .. code-block:: python 64 | 65 | @staff_member_required 66 | @permission_required('auth.view_user') 67 | def my_view(request): 68 | ... 69 | 70 | Added support for decorators within ``@method_decorator`` on class based views: 71 | 72 | .. code-block:: python 73 | 74 | class MyView(View): 75 | @method_decorator(staff_member_required) 76 | @method_decorator(permission_required('auth.view_user')) 77 | def dispatch(self, request, *args, **kwargs): 78 | ... 79 | 80 | - Refactored test suite to be much cleaner. 81 | 82 | 83 | v0.5.1 (Released 9/23/2019) 84 | --------------------------- 85 | 86 | - Added error message when multiple permissions are found for a single permission string in the django admin. 87 | 88 | 89 | v0.5.0 (Released 2/12/2019) 90 | --------------------------- 91 | 92 | - The django Groups admin list is now overridden instead of adding a custom one (this can be configured via ``PERMISSIONS_AUDITOR_ADMIN_OVERRIDE_GROUPS`` setting.) 93 | - Added ``check_view_permissions`` management command. 94 | 95 | 96 | v0.4.3 (Released 1/28/2019) 97 | --------------------------- 98 | 99 | - Fixed an issue which caused the app to create migrations for models that didn't exist. 100 | 101 | 102 | v0.4.2 (Released 1/23/2019) 103 | --------------------------- 104 | 105 | - Fixed permission check for groups listing (uses the default Django 'auth.change_group', 'auth.view_group') 106 | - Fixed N+1 query in groups listing 107 | 108 | 109 | v0.4.1 (Released 1/22/2019) 110 | --------------------------- 111 | 112 | - Fixed app inadvertently creating migrations on the `Group` model. 113 | 114 | 115 | v0.4.0 (Release Removed) 116 | --------------------------- 117 | 118 | - Added groups listing to the admin site. 119 | 120 | 121 | v0.3.3 (Released 1/9/2019) 122 | -------------------------- 123 | 124 | - Marked docstrings as safe in admin templates. 125 | - Inner exceptions on processors are no longer suppressed when parsing views. 126 | - Fixed Django admin module permissions check. 127 | 128 | 129 | v0.3.2 (Released 1/9/2019) 130 | -------------------------- 131 | 132 | - Fixed various cache issues 133 | - Only show active users in the admin permission configuration page 134 | 135 | 136 | v0.3.1 (Released 1/8/2019) 137 | -------------------------- 138 | 139 | - Initial stable release 140 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | import django 20 | os.environ['DJANGO_SETTINGS_MODULE'] = 'permissions_auditor.tests.test_settings' 21 | django.setup() 22 | 23 | import permissions_auditor 24 | 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | project = 'django-permissions-auditor' 29 | copyright = '2019' 30 | author = 'AAC Engineering' 31 | 32 | # The short X.Y version 33 | version = '.'.join(permissions_auditor.__version__.split('.')[0:2]) 34 | # The full version, including alpha/beta/rc tags 35 | release = permissions_auditor.__version__ 36 | 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # If your documentation needs a minimal Sphinx version, state it here. 41 | # 42 | # needs_sphinx = '1.0' 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions = [ 48 | 'sphinx.ext.doctest', 49 | 'sphinx.ext.autodoc', 50 | ] 51 | 52 | # Add any paths that contain templates here, relative to this directory. 53 | templates_path = ['_templates'] 54 | 55 | # The suffix(es) of source filenames. 56 | # You can specify multiple suffix as a list of string: 57 | # 58 | # source_suffix = ['.rst', '.md'] 59 | source_suffix = '.rst' 60 | 61 | # The master toctree document. 62 | master_doc = 'index' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This pattern also affects html_static_path and html_extra_path. 74 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | 80 | # -- Options for HTML output ------------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | html_theme = 'sphinx_rtd_theme' 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ['_static'] 97 | 98 | # Custom sidebar templates, must be a dictionary that maps document names 99 | # to template names. 100 | # 101 | # The default sidebars (for documents that don't match any pattern) are 102 | # defined by theme itself. Builtin themes are using these templates by 103 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 104 | # 'searchbox.html']``. 105 | # 106 | # html_sidebars = {} 107 | 108 | 109 | # -- Options for HTMLHelp output --------------------------------------------- 110 | 111 | # Output file base name for HTML help builder. 112 | htmlhelp_basename = 'django-permissions-auditordoc' 113 | 114 | 115 | # -- Options for LaTeX output ------------------------------------------------ 116 | 117 | latex_elements = { 118 | # The paper size ('letterpaper' or 'a4paper'). 119 | # 120 | # 'papersize': 'letterpaper', 121 | 122 | # The font size ('10pt', '11pt' or '12pt'). 123 | # 124 | # 'pointsize': '10pt', 125 | 126 | # Additional stuff for the LaTeX preamble. 127 | # 128 | # 'preamble': '', 129 | 130 | # Latex figure (float) alignment 131 | # 132 | # 'figure_align': 'htbp', 133 | } 134 | 135 | # Grouping the document tree into LaTeX files. List of tuples 136 | # (source start file, target name, title, 137 | # author, documentclass [howto, manual, or own class]). 138 | latex_documents = [ 139 | (master_doc, 'django-permissions-auditor.tex', 'django-permissions-auditor Documentation', 140 | 'Christian Klus', 'manual'), 141 | ] 142 | 143 | 144 | # -- Options for manual page output ------------------------------------------ 145 | 146 | # One entry per manual page. List of tuples 147 | # (source start file, name, description, authors, manual section). 148 | man_pages = [ 149 | (master_doc, 'django-permissions-auditor', 'django-permissions-auditor Documentation', 150 | [author], 1) 151 | ] 152 | 153 | 154 | # -- Options for Texinfo output ---------------------------------------------- 155 | 156 | # Grouping the document tree into Texinfo files. List of tuples 157 | # (source start file, target name, title, author, 158 | # dir menu entry, description, category) 159 | texinfo_documents = [ 160 | (master_doc, 'django-permissions-auditor', 'django-permissions-auditor Documentation', 161 | author, 'django-permissions-auditor', 'One line description of project.', 162 | 'Miscellaneous'), 163 | ] 164 | 165 | 166 | # -- Options for Epub output ------------------------------------------------- 167 | 168 | # Bibliographic Dublin Core info. 169 | epub_title = project 170 | 171 | # The unique identifier of the text. This can be a ISBN number 172 | # or the project homepage. 173 | # 174 | # epub_identifier = '' 175 | 176 | # A unique identification for the text. 177 | # 178 | # epub_uid = '' 179 | 180 | # A list of files that should not be packed into the epub file. 181 | epub_exclude_files = ['search.html'] 182 | 183 | 184 | # -- Extension configuration ------------------------------------------------- 185 | -------------------------------------------------------------------------------- /docs/custom_processors.rst: -------------------------------------------------------------------------------- 1 | Custom Processors 2 | =========================== 3 | 4 | In situations where custom permission schemes are used, and are not detected by permissions auditor out of the box, you may need to write a custom processor. 5 | 6 | 7 | Base Processors 8 | --------------- 9 | 10 | All processors inherit from ``BaseProcessor``. 11 | 12 | .. autoclass:: permissions_auditor.processors.base.BaseProcessor 13 | :members: 14 | 15 | 16 | Other useful base classes: 17 | 18 | .. autoclass:: permissions_auditor.processors.base.BaseFuncViewProcessor 19 | 20 | .. autoclass:: permissions_auditor.processors.base.BaseCBVProcessor 21 | 22 | .. autoclass:: permissions_auditor.processors.base.BaseDecoratorProcessor 23 | 24 | .. autoclass:: permissions_auditor.processors.base.BaseFilteredMixinProcessor 25 | :members: class_filter, get_class_filter 26 | 27 | 28 | Parsing Mixins 29 | -------------- 30 | 31 | Creating a custom processor for mixins on class based views is fairly straight forward. 32 | 33 | In this example, we have a mixin ``BobRequiredMixin`` and a view that uses it, ``BobsPage``. 34 | The mixin should only allow users with the first name Bob to access the page. 35 | 36 | 37 | .. code-block:: python 38 | :caption: example_project/views.py 39 | 40 | from django.core.exceptions import PermissionDenied 41 | from django.views.generic import TemplateView 42 | 43 | class BobRequiredMixin: 44 | def dispatch(self, request, *args, **kwargs): 45 | if self.request.user.first_name != 'Bob': 46 | raise PermissionDenied("You are not Bob") 47 | return super().dispatch(request, *args, **kwargs) 48 | 49 | class BobsPage(BobRequiredMixin, TemplateView): 50 | ... 51 | 52 | 53 | Let's define our processor in `processors.py`. 54 | 55 | .. code-block:: python 56 | :caption: example_project/processors.py 57 | 58 | from permissions_auditor.processors.base import BaseFilteredMixinProcessor 59 | 60 | class BobRequiredMixinProcessor(BaseFilteredMixinProcessor): 61 | class_filter = 'example_project.views.BobRequiredMixin' 62 | 63 | def get_login_required(self, view): 64 | return True 65 | 66 | def get_docstring(self, view): 67 | return "The user's first name must be Bob to view." 68 | 69 | 70 | To register our processor, we need to add it to :ref:`PERMISSIONS_AUDITOR_PROCESSORS` 71 | in our project settings. 72 | 73 | 74 | .. code-block:: python 75 | :caption: settings.py 76 | :emphasize-lines: 4 77 | 78 | PERMISSIONS_AUDITOR_PROCESSORS = [ 79 | ... 80 | 81 | 'example_project.processors.BobRequiredProcessor', 82 | ] 83 | 84 | 85 | When ``BobsPage`` is registered to a URL, we should see this in the admin panel: 86 | 87 | +-------------+-----+---------------------+----------------+--------------------------------------------+ 88 | | Name | URL | Permission Required | Login Required | Additional Info | 89 | +=============+=====+=====================+================+============================================+ 90 | | BobsPage | `/` | | True | The user's first name must be Bob to view. | 91 | +-------------+-----+---------------------+----------------+--------------------------------------------+ 92 | 93 | 94 | Perhaps we want to make our mixin configurable so we can detect different names depending on the view. 95 | We also have multiple people with the same first name, so we also want to check for a permission: 96 | ``example.view_pages``. 97 | 98 | 99 | .. code-block:: python 100 | 101 | class FirstNameRequiredMixin: 102 | required_first_name = '' 103 | 104 | def dispatch(self, request, *args, **kwargs): 105 | if not (self.request.user.has_perm('example_app.view_userpages') 106 | and self.request.user.first_name == self.required_first_name): 107 | raise PermissionDenied() 108 | return super().dispatch(request, *args, **kwargs) 109 | 110 | class GeorgesPage(FirstNameRequiredMixin, TemplateView): 111 | required_first_name = 'George' 112 | 113 | ... 114 | 115 | We'll modify ``class_filter`` and ``get_docstring()`` from our old processor, and override 116 | ``get_permission_required()``. 117 | 118 | .. code-block:: python 119 | 120 | from permissions_auditor.processors.base import BaseFilteredMixinProcessor 121 | 122 | class FirstNameRequiredMixinProcessor(BaseFilteredMixinProcessor): 123 | class_filter = 'example_project.views.FirstNameRequiredMixin' 124 | 125 | def get_permission_required(self, view): 126 | return ['example.view_pages'] 127 | 128 | def get_login_required(self, view): 129 | return True 130 | 131 | def get_docstring(self, view): 132 | return "The user's first name must be {} to view.".format(view.first_name_required) 133 | 134 | 135 | Once we register our view to a URL and register the processor, our admin table should look like this: 136 | 137 | +-------------+-----+---------------------+----------------+-----------------------------------------------+ 138 | | Name | URL | Permission Required | Login Required | Additional Info | 139 | +=============+=====+=====================+================+===============================================+ 140 | | GeorgesPage | `/` | example.view_pages | True | The user's first name must be George to view. | 141 | +-------------+-----+---------------------+----------------+-----------------------------------------------+ 142 | 143 | 144 | Additional Examples 145 | ------------------- 146 | 147 | See the ``permissions_auditor/processors/`` folder in the source code for more examples. 148 | -------------------------------------------------------------------------------- /docs/example.rst: -------------------------------------------------------------------------------- 1 | Example Views 2 | ============= 3 | 4 | The following are example views are detected out of the box. For more examples, see 5 | ``permissions_auditor/tests/fixtures/views.py``. 6 | 7 | 8 | Simple Permission Required Page 9 | ------------------------------- 10 | 11 | .. code-block:: python 12 | :caption: views.py 13 | 14 | from django.contrib.auth.mixins import PermissionRequiredMixin 15 | from django.views.generic import TemplateView 16 | 17 | class ExampleView(PermissionRequiredMixin, TemplateView): 18 | template_name = 'example.html' 19 | permission_required = 'auth.view_user' 20 | 21 | ... 22 | 23 | 24 | .. code-block:: python 25 | :caption: urls.py 26 | 27 | from django.urls import path 28 | from views import ExampleView 29 | 30 | urlpatterns = [ 31 | path('', ExampleView.as_view(), name='example'), 32 | ] 33 | 34 | 35 | Result: 36 | 37 | +-------------+-----+---------------------+----------------+------------------+ 38 | | Name | URL | Permission Required | Login Required | Additional Info | 39 | +=============+=====+=====================+================+==================+ 40 | | ExampleView | `/` | ``auth.view_user`` | True | | 41 | +-------------+-----+---------------------+----------------+------------------+ 42 | 43 | 44 | 45 | Custom Permission Required Page 46 | -------------------------------- 47 | 48 | In this example, we only want users with the first name 'bob' to be able to 49 | access the page. 50 | 51 | .. code-block:: python 52 | :caption: views.py 53 | 54 | from django.contrib.auth.mixins import PermissionRequiredMixin 55 | from django.views.generic import TemplateView 56 | 57 | class BobView(PermissionRequiredMixin, TemplateView): 58 | template_name = 'example.html' 59 | 60 | def has_permission(self): 61 | """ 62 | Only users with the first name Bob can access. 63 | """ 64 | return self.request.user.first_name == 'Bob' 65 | 66 | ... 67 | 68 | 69 | .. code-block:: python 70 | :caption: urls.py 71 | 72 | from django.urls import path 73 | from views import BobView 74 | 75 | urlpatterns = [ 76 | path('/bob/', BobView.as_view(), name='bob'), 77 | ] 78 | 79 | 80 | Result: 81 | 82 | +-------------+---------+---------------------+----------------+----------------------------+ 83 | | Name | URL | Permission Required | Login Required | Additional Info | 84 | +=============+=========+=====================+================+============================+ 85 | | ExampleView | `/bob/` | | True | Only users with the first | 86 | | | | | | name Bob can access. | 87 | +-------------+---------+---------------------+----------------+----------------------------+ 88 | 89 | .. hint:: 90 | The :ref:`PermissionRequiredMixinProcessor` will display the docstring on the the 91 | ``has_permission()`` function in the additional info column. 92 | 93 | 94 | 95 | Simple Login Required View 96 | -------------------------- 97 | 98 | .. code-block:: python 99 | :caption: views.py 100 | 101 | from django.contrib.auth.decorators import login_required 102 | 103 | @login_required 104 | def my_view(request): 105 | ... 106 | 107 | 108 | .. code-block:: python 109 | :caption: urls.py 110 | 111 | from django.urls import path 112 | from views import my_view 113 | 114 | urlpatterns = [ 115 | path('', my_view, name='example'), 116 | ] 117 | 118 | 119 | Result: 120 | 121 | +-------------+-----+---------------------+----------------+------------------+ 122 | | Name | URL | Permission Required | Login Required | Additional Info | 123 | +=============+=====+=====================+================+==================+ 124 | | my_view | `/` | | True | | 125 | +-------------+-----+---------------------+----------------+------------------+ 126 | -------------------------------------------------------------------------------- /docs/img/admin_groups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/docs/img/admin_groups.png -------------------------------------------------------------------------------- /docs/img/admin_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/docs/img/admin_permissions.png -------------------------------------------------------------------------------- /docs/img/admin_section.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/docs/img/admin_section.png -------------------------------------------------------------------------------- /docs/img/admin_views.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/docs/img/admin_views.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-permissions-auditor documentation master file, created by 2 | sphinx-quickstart on Thu Jan 3 10:52:36 2019. 3 | 4 | django-permissions-auditor 5 | ====================================================== 6 | 7 | 8 | Admin site for auditing and managing permissions for views in your Django app. 9 | 10 | 11 | .. image:: img/admin_views.png 12 | 13 | 14 | Features 15 | -------- 16 | 17 | * Automatically parse views registered in Django's URL system 18 | * Out of the box support for Django's authentication system 19 | * Easily extensible for custom permission schemes 20 | 21 | 22 | .. include:: installation.rst 23 | 24 | 25 | Contribute 26 | ---------- 27 | 28 | - Issue Tracker: https://github.com/AACEngineering/django-permissions-auditor/issues 29 | - Source Code: https://github.com/AACEngineering/django-permissions-auditor 30 | 31 | License 32 | ------- 33 | 34 | The project is licensed under the MIT license. 35 | 36 | 37 | .. toctree:: 38 | :maxdepth: 2 39 | :caption: Contents: 40 | 41 | overview 42 | installation 43 | settings 44 | admin_site 45 | management_commands 46 | example 47 | processors/index 48 | custom_processors 49 | changelog 50 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements: 5 | 6 | * Django 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0 7 | * Python 3.8, 3.9, 3.10, 3.11, 3.12 8 | 9 | 10 | To install:: 11 | 12 | pip install django-permissions-auditor 13 | 14 | 15 | Add `permissions_auditor` to your ``INSTALLED_APPS`` in your project's ``settings.py`` file:: 16 | 17 | INSTALLED_APPS = [ 18 | ... 19 | 'permissions_auditor', 20 | ... 21 | ] 22 | 23 | 24 | That's it! A permissions auditor section will now show in your site's admin page. To fine tune what is displayed, head over to the :ref:`Settings` page. 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/management_commands.rst: -------------------------------------------------------------------------------- 1 | Management Commands 2 | =================== 3 | 4 | check_view_permissions 5 | ---------------------- 6 | 7 | :synopsis: Checks that all detected view permissions exist in the database. 8 | 9 | Uses permissions found on your project's views and compares the with the permissions 10 | that exist within the database. Useful for catching typos when specifying permissions. 11 | 12 | 13 | Example Usage 14 | ^^^^^^^^^^^^^ 15 | 16 | :: 17 | 18 | $ python manage.py check_view_permissions 19 | 20 | 21 | 22 | dump_view_permissions 23 | ---------------------- 24 | 25 | :synopsis: Dumps all detected view permissions to the specified output format. 26 | 27 | If no parameters are provided, outputs JSON formatted results to ``stdout``. 28 | 29 | Supports ``csv`` and ``json`` formats using ``--format`` or ``-f`` parameter. 30 | Output to a file using ``--output`` or ``-o``, similar to Django's ``dumpdata`` command. 31 | 32 | Example Usage 33 | ^^^^^^^^^^^^^ 34 | 35 | :: 36 | 37 | $ python manage.py dump_view_permissions 38 | 39 | $ python manage.py dump_view_permissions --format csv 40 | 41 | $ python manage.py dump_view_permissions --format csv --output example_file.csv 42 | 43 | $ python manage.py dump_view_permissions --format json 44 | 45 | $ python manage.py dump_view_permissions --format csv --output example_file.json 46 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | In large Django applications that require complex access control, it can be difficult 5 | for site administrators to effectively assign and manage permissions for users and groups. 6 | 7 | I often found that I needed to reference my site's source code in order to remember 8 | what permission was required for what view - something end-users and managers shouldn't 9 | need to do. 10 | 11 | Django-permissions-auditor attempts to solve this problem by automatically parsing 12 | out permissions so that administrators can easily manage their site. 13 | -------------------------------------------------------------------------------- /docs/processors/auth_decorators.rst: -------------------------------------------------------------------------------- 1 | Django Auth Decorator Processors 2 | ================================ 3 | 4 | PermissionRequiredDecoratorProcessor 5 | ------------------------------------------ 6 | 7 | .. autoclass:: permissions_auditor.processors.auth_decorators.PermissionRequiredDecoratorProcessor 8 | 9 | 10 | LoginRequiredDecoratorProcessor 11 | ------------------------------------------ 12 | 13 | .. autoclass:: permissions_auditor.processors.auth_decorators.LoginRequiredDecoratorProcessor 14 | 15 | 16 | StaffMemberRequiredDecoratorProcessor 17 | ------------------------------------------ 18 | 19 | .. autoclass:: permissions_auditor.processors.auth_decorators.StaffMemberRequiredDecoratorProcessor 20 | 21 | 22 | ActiveUserRequiredDecoratorProcessor 23 | ------------------------------------------ 24 | 25 | .. autoclass:: permissions_auditor.processors.auth_decorators.ActiveUserRequiredDecoratorProcessor 26 | 27 | 28 | AnonymousUserRequiredDecoratorProcessor 29 | ------------------------------------------ 30 | 31 | .. autoclass:: permissions_auditor.processors.auth_decorators.AnonymousUserRequiredDecoratorProcessor 32 | 33 | 34 | SuperUserRequiredDecoratorProcessor 35 | ------------------------------------------ 36 | 37 | .. autoclass:: permissions_auditor.processors.auth_decorators.SuperUserRequiredDecoratorProcessor 38 | 39 | 40 | UserPassesTestDecoratorProcessor 41 | ------------------------------------------ 42 | 43 | .. autoclass:: permissions_auditor.processors.auth_decorators.UserPassesTestDecoratorProcessor 44 | -------------------------------------------------------------------------------- /docs/processors/auth_mixins.rst: -------------------------------------------------------------------------------- 1 | Django Auth Mixin Processors 2 | ============================ 3 | 4 | .. _PermissionRequiredMixinProcessor: 5 | 6 | PermissionRequiredMixinProcessor 7 | ------------------------------------------ 8 | 9 | .. autoclass:: permissions_auditor.processors.auth_mixins.PermissionRequiredMixinProcessor 10 | 11 | 12 | LoginRequiredMixinProcessor 13 | ------------------------------------------ 14 | 15 | .. autoclass:: permissions_auditor.processors.auth_mixins.LoginRequiredMixinProcessor 16 | 17 | 18 | UserPassesTestMixinProcessor 19 | ------------------------------------------ 20 | 21 | .. autoclass:: permissions_auditor.processors.auth_mixins.UserPassesTestMixinProcessor 22 | -------------------------------------------------------------------------------- /docs/processors/index.rst: -------------------------------------------------------------------------------- 1 | .. _processors: 2 | 3 | Included Processors 4 | =================== 5 | 6 | Processors are what do the work of parsing the permissions out of a view. 7 | 8 | .. toctree:: 9 | :maxdepth: 3 10 | :caption: Default Processors: 11 | 12 | Django Auth Decorators 13 | Django Auth Mixins 14 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | pillow 4 | commonmark 5 | myst_parser 6 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | .. _Settings: 2 | 3 | Settings 4 | ================== 5 | 6 | 7 | .. _PERMISSIONS_AUDITOR_PROCESSORS: 8 | 9 | PERMISSIONS_AUDITOR_PROCESSORS 10 | -------------------------------------- 11 | 12 | This setting is used to configure the processors used to parse views for their permissions. 13 | You can add custom processors, or remove the default ones similar to Django's middleware system. 14 | 15 | For details on each processor, see :ref:`processors`. 16 | 17 | Default:: 18 | 19 | PERMISSIONS_AUDITOR_PROCESSORS = [ 20 | 'permissions_auditor.processors.auth_mixins.PermissionRequiredMixinProcessor', 21 | 'permissions_auditor.processors.auth_mixins.LoginRequiredMixinProcessor', 22 | 'permissions_auditor.processors.auth_mixins.UserPassesTestMixinProcessor', 23 | 'permissions_auditor.processors.auth_decorators.PermissionRequiredDecoratorProcessor', 24 | 'permissions_auditor.processors.auth_decorators.LoginRequiredDecoratorProcessor', 25 | 'permissions_auditor.processors.auth_decorators.StaffMemberRequiredDecoratorProcessor', 26 | 'permissions_auditor.processors.auth_decorators.SuperUserRequiredDecoratorProcessor', 27 | 'permissions_auditor.processors.auth_decorators.UserPassesTestDecoratorProcessor', 28 | ] 29 | 30 | 31 | 32 | .. _PERMISSIONS_AUDITOR_BLACKLIST: 33 | 34 | PERMISSIONS_AUDITOR_BLACKLIST 35 | -------------------------------------- 36 | 37 | Exclude views from parsing that match the blacklist values. 38 | 39 | Default:: 40 | 41 | PERMISSIONS_AUDITOR_BLACKLIST = { 42 | 'namespaces': [ 43 | 'admin', 44 | ], 45 | 'view_names': [ 46 | 'django.views.generic.base.RedirectView', 47 | ], 48 | 'modules': [], 49 | } 50 | 51 | :namespaces: URL namespaces that will be blacklisted. By default, all views in the ``admin`` namespace are blacklisted. 52 | :view_names: Fully qualified view paths to be blacklisted. Example: ``test_app.views.home_page``. 53 | :modules: Modules to be blacklisted. Example: ``test_app.views.function_based``. 54 | 55 | 56 | 57 | .. _PERMISSIONS_AUDITOR_ADMIN: 58 | 59 | PERMISSIONS_AUDITOR_ADMIN 60 | -------------------------------------- 61 | 62 | Enable or disable the Django admin page provided by the app. If ``TRUE``, the admin site will be enabled. 63 | Useful if you want to create a custom management page instead of using the Django admin. 64 | 65 | Default: ``TRUE`` 66 | 67 | 68 | 69 | .. _PERMISSIONS_AUDITOR_ADMIN_OVERRIDE_GROUPS: 70 | 71 | PERMISSIONS_AUDITOR_ADMIN_OVERRIDE_GROUPS 72 | ----------------------------------------- 73 | 74 | Override the default django groups admin with the permissions auditor version. Has no effect if 75 | :ref:`PERMISSIONS_AUDITOR_ADMIN` is set to ``False``. 76 | 77 | Default: ``True`` 78 | 79 | 80 | 81 | .. _PERMISSIONS_AUDITOR_ROOT_URLCONF: 82 | 83 | PERMISSIONS_AUDITOR_ROOT_URLCONF 84 | -------------------------------------- 85 | 86 | The root Django URL configuration to use when fetching views. 87 | 88 | Default: The ``ROOT_URLCONF`` value in your Django project's ``settings.py`` file. 89 | 90 | 91 | 92 | .. _PERMISSIONS_AUDITOR_CACHE_KEY: 93 | 94 | PERMISSIONS_AUDITOR_CACHE_KEY 95 | -------------------------------------- 96 | 97 | The cache key prefix to use when caching processed views results. 98 | 99 | Default: ``'permissions_auditor_views'`` 100 | 101 | 102 | 103 | .. _PERMISSIONS_AUDITOR_CACHE_TIMEOUT: 104 | 105 | PERMISSIONS_AUDITOR_CACHE_TIMEOUT 106 | -------------------------------------- 107 | 108 | The timeout to use when caching processed views results. 109 | 110 | Default: ``900`` 111 | 112 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'y1$c+(^$$kb79le9_)oo73k80&at6oo6lod)(fv2&!95iw5)lc' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'test_app', 42 | 'permissions_auditor', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'example.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'example.wsgi.application' 74 | 75 | 76 | # Cache 77 | # https://docs.djangoproject.com/en/2.1/topics/cache/ 78 | 79 | CACHES = { 80 | 'default': { 81 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 82 | 'LOCATION': 'unique-snowflake', 83 | } 84 | } 85 | 86 | 87 | # Database 88 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 89 | 90 | DATABASES = { 91 | 'default': { 92 | 'ENGINE': 'django.db.backends.sqlite3', 93 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 94 | } 95 | } 96 | 97 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 98 | 99 | 100 | # Password validation 101 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 102 | 103 | AUTH_PASSWORD_VALIDATORS = [ 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 106 | }, 107 | { 108 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 109 | }, 110 | { 111 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 112 | }, 113 | { 114 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 115 | }, 116 | ] 117 | 118 | 119 | # Internationalization 120 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 121 | 122 | LANGUAGE_CODE = 'en-us' 123 | 124 | TIME_ZONE = 'UTC' 125 | 126 | USE_I18N = True 127 | 128 | USE_L10N = True 129 | 130 | USE_TZ = True 131 | 132 | 133 | # Static files (CSS, JavaScript, Images) 134 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 135 | 136 | STATIC_URL = '/static/' 137 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 18 | from django.urls import path, include 19 | 20 | from test_app import urls 21 | 22 | admin.autodiscover() 23 | 24 | urlpatterns = [ 25 | path('admin/', admin.site.urls), 26 | path('', include(urls)), 27 | ] + staticfiles_urlpatterns() 28 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | # Import parent path 6 | sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 7 | 8 | if __name__ == '__main__': 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | -------------------------------------------------------------------------------- /example/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/example/test_app/__init__.py -------------------------------------------------------------------------------- /example/test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import ContentType, Permission 2 | from django.contrib import admin 3 | 4 | admin.site.register(ContentType) 5 | admin.site.register(Permission) 6 | -------------------------------------------------------------------------------- /example/test_app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Permissions Auditor Example App 4 | 5 | 6 | {% block content %}{% endblock content %} 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/test_app/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |

Permissions Auditor Example App

5 | 6 | 12 | 13 | Logout 14 | {% endblock content %} 15 | -------------------------------------------------------------------------------- /example/test_app/templates/permissions_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |

Permissions Auditor Permissions List Example

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for view in views %} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% endfor %} 28 | 29 |
ModuleView NameURLPermissionsLogin RequiredAdditional Info
{{ view.module }}{{ view.name }}{{ view.url }}{{ view.permissions }}{{ view.login_required|default_if_none:"Unknown" }}{{ view.docstring }}
30 | 31 | {% endblock content %} 32 | -------------------------------------------------------------------------------- /example/test_app/templates/users.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |

Permissions Auditor Example Users

5 | 6 | 11 | 12 | {% endblock content %} 13 | -------------------------------------------------------------------------------- /example/test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import PasswordChangeView 2 | from django.urls import path, include 3 | 4 | from test_app import views 5 | from test_app.views import cbv_based, function_based 6 | 7 | cbv_namespace = ([ 8 | path('users/', cbv_based.UserIndex.as_view(), name='user_index'), 9 | path('super_users/', cbv_based.SuperUserIndex.as_view(), name='superuser_index'), 10 | path('permissions/', cbv_based.PermissionsIndex.as_view(), name='permissions_index'), 11 | ], 'cbv') 12 | 13 | func_namespaces = ([ 14 | path('users/', function_based.user_index, name='user_index'), 15 | path('super_users/', function_based.superuser_index, name='superuser_index'), 16 | path('permissions/', function_based.permissions_index, name='permissions_index'), 17 | path('invalid/', function_based.invalid_permission_view, name='invalid_permission_view'), 18 | ], 'func') 19 | 20 | urlpatterns = [ 21 | path('', views.home_page, name='home'), 22 | 23 | path('accounts/login/', views.LoginPage.as_view(), name='login'), 24 | path('accounts/logout/', views.LogoutPage.as_view(), name='logout'), 25 | path('accounts/password/change/', PasswordChangeView.as_view(), name='change_password'), 26 | 27 | path('cbv/', include(cbv_namespace)), 28 | path('func/', include(func_namespaces)), 29 | ] 30 | -------------------------------------------------------------------------------- /example/test_app/views/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.contrib.auth.views import LoginView, LogoutView 3 | from django.shortcuts import render 4 | 5 | 6 | class LoginPage(LoginView): 7 | template_name = 'admin/login.html' 8 | 9 | 10 | class LogoutPage(LogoutView): 11 | next_page = 'home' 12 | 13 | 14 | @login_required 15 | def home_page(request): 16 | return render(request, 'home.html') 17 | -------------------------------------------------------------------------------- /example/test_app/views/cbv_based.py: -------------------------------------------------------------------------------- 1 | from permissions_auditor.core import get_views 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin 5 | from django.views.generic import TemplateView 6 | 7 | User = get_user_model() 8 | 9 | 10 | class UserIndex(PermissionRequiredMixin, TemplateView): 11 | template_name = 'users.html' 12 | permission_required = 'auth.view_user' 13 | 14 | def has_permission(self): 15 | """PermissionRequiredMixin - Docstrings on `has_permission()` are displayed in the admin.""" 16 | return super().has_permission() 17 | 18 | def get_context_data(self, **kwargs): 19 | context = super().get_context_data(**kwargs) 20 | context['users'] = User.objects.filter(is_superuser=False) 21 | return context 22 | 23 | 24 | class SuperUserIndex(UserPassesTestMixin, TemplateView): 25 | template_name = 'users.html' 26 | permission_required = 'auth.view_user' 27 | 28 | def test_func(self): 29 | """UserPassesTestMixin - The user must be a superuser to access.""" 30 | return self.request.user.is_superuser 31 | 32 | def get_context_data(self, **kwargs): 33 | context = super().get_context_data(**kwargs) 34 | context['users'] = User.objects.filter(is_superuser=True) 35 | return context 36 | 37 | 38 | class PermissionsIndex(PermissionRequiredMixin, TemplateView): 39 | template_name = 'permissions_list.html' 40 | permission_required = ('auth.view_user', 'auth.change_user',) 41 | 42 | def get_context_data(self, **kwargs): 43 | context = super().get_context_data(**kwargs) 44 | context['views'] = get_views() 45 | return context 46 | -------------------------------------------------------------------------------- /example/test_app/views/function_based.py: -------------------------------------------------------------------------------- 1 | from permissions_auditor.core import get_views 2 | 3 | from django.contrib.admin.views.decorators import staff_member_required 4 | from django.contrib.auth.decorators import permission_required, user_passes_test 5 | from django.contrib.auth import get_user_model 6 | from django.shortcuts import render 7 | 8 | User = get_user_model() 9 | 10 | 11 | @permission_required('auth.view_user') 12 | def user_index(request): 13 | context = { 14 | 'users': User.objects.filter(is_superuser=False) 15 | } 16 | return render(request, 'users.html', context) 17 | 18 | 19 | @user_passes_test(lambda u: u.is_superuser) 20 | def superuser_index(request): 21 | context = { 22 | 'users': User.objects.filter(is_superuser=True) 23 | } 24 | return render(request, 'users.html', context) 25 | 26 | 27 | @staff_member_required 28 | def permissions_index(request): 29 | context = { 30 | 'views': get_views() 31 | } 32 | return render(request, 'permissions_list.html', context) 33 | 34 | 35 | @permission_required('perm.does_not_exist') 36 | def invalid_permission_view(request): 37 | return render(request, 'base.html', {}) 38 | -------------------------------------------------------------------------------- /permissions_auditor/__init__.py: -------------------------------------------------------------------------------- 1 | "Permissions auditing for Django." 2 | 3 | __version__ = '1.2.0' 4 | 5 | # This can be removed once Django 3.1 and below support is dropped. 6 | default_app_config = 'permissions_auditor.apps.PermissionsAuditorConfig' 7 | -------------------------------------------------------------------------------- /permissions_auditor/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.contrib.admin import helpers 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.admin import GroupAdmin 5 | from django.contrib.auth.models import Group, Permission 6 | from django.db import models 7 | from django.db.models import Prefetch 8 | from django.http import HttpResponseRedirect 9 | from django.template.response import TemplateResponse 10 | from django.urls import path, reverse 11 | from django.utils.html import mark_safe 12 | from django.utils.translation import gettext_lazy as _ 13 | 14 | from permissions_auditor.core import get_views, _get_setting 15 | from permissions_auditor.forms import AuditorAdminPermissionForm 16 | 17 | 18 | class View(models.Model): 19 | """Dummy model to display views index pages in the admin.""" 20 | class Meta: 21 | managed = False 22 | verbose_name = 'permission' 23 | verbose_name_plural = _('Site Views') 24 | app_label = 'permissions_auditor' 25 | 26 | 27 | class ViewsIndexAdmin(admin.ModelAdmin): 28 | """ 29 | Index containing all of the views found on the django site, 30 | and the permissions needed to access them. 31 | """ 32 | form = AuditorAdminPermissionForm 33 | fieldsets = ( 34 | (_('Permission Info'), { 35 | 'fields': ('name', 'content_type', 'codename'), 36 | }), 37 | (_('Objects with this Permission'), { 38 | 'fields': ('users', 'groups'), 39 | }), 40 | ) 41 | 42 | def get_urls(self): 43 | info = self.model._meta.app_label, self.model._meta.model_name 44 | return [ 45 | path('', self.admin_site.admin_view(self.index), name='%s_%s_changelist' % info), 46 | path('/', 47 | self.admin_site.admin_view(self.permission_detail), 48 | name='%s_%s_permissiondetail' % info), 49 | ] 50 | 51 | def get_object(self, request, permission, from_field=None): 52 | try: 53 | app_label, codename = permission.split('.') 54 | return Permission.objects.get(content_type__app_label=app_label, codename=codename) 55 | except (Permission.DoesNotExist, ValueError): 56 | return None 57 | 58 | def get_form(self, request, obj, change=False, **kwargs): 59 | defaults = { 60 | 'users': obj.user_set.filter(is_active=True), 61 | 'groups': obj.group_set.all() 62 | } 63 | return self.form(request.POST or defaults, instance=obj) 64 | 65 | def index(self, request): 66 | context = dict(self.admin_site.each_context(request)) 67 | 68 | context.update({ 69 | 'views': get_views(), 70 | 'group_by': request.GET.get('group_by', 'module') 71 | }) 72 | return TemplateResponse(request, "permissions_auditor/admin/views_index.html", context) 73 | 74 | def permission_detail(self, request, permission, obj=None): 75 | try: 76 | obj = self.get_object(request, permission) 77 | except Permission.MultipleObjectsReturned: 78 | return self._get_obj_multiple_exist_redirect(request, permission) 79 | 80 | if obj is None: 81 | return self._get_obj_does_not_exist_redirect(request, self.model._meta, permission) 82 | 83 | opts = self.model._meta 84 | 85 | adminForm = helpers.AdminForm( 86 | self.get_form(request, obj), 87 | list(self.get_fieldsets(request, obj)), 88 | {}, 89 | model_admin=self 90 | ) 91 | media = self.media + adminForm.media 92 | 93 | if (request.method == 'POST' and 94 | adminForm.form.is_valid() and 95 | self.has_auditor_change_permission(request)): 96 | obj.user_set.set(adminForm.form.cleaned_data['users']) 97 | obj.group_set.set(adminForm.form.cleaned_data['groups']) 98 | return self.response_change(request, obj) 99 | 100 | context = { 101 | **self.admin_site.each_context(request), 102 | 'adminform': adminForm, 103 | 'errors': helpers.AdminErrorList(adminForm.form, []), 104 | 'media': media, 105 | 106 | 'views': get_views(), 107 | 'permission': '{}.{}'.format(obj.content_type.app_label, obj.codename), 108 | 109 | 'opts': opts, 110 | 'add': False, 111 | 'change': True, 112 | 'is_popup': False, 113 | 'save_as': self.save_as, 114 | 'has_editable_inline_admin_formsets': False, 115 | 'has_view_permission': self.has_view_permission(request, obj), 116 | 'has_add_permission': self.has_add_permission(request, obj), 117 | 'has_change_permission': self.has_auditor_change_permission(request), 118 | 'has_delete_permission': self.has_delete_permission(request, obj), 119 | 'app_label': opts.app_label, 120 | } 121 | 122 | return TemplateResponse( 123 | request, "permissions_auditor/admin/permission_detail.html", context 124 | ) 125 | 126 | def has_view_permission(self, request, obj=None): 127 | return request.user.is_staff 128 | 129 | def has_add_permission(self, request, obj=None): 130 | return False 131 | 132 | def has_change_permission(self, request, obj=None): 133 | return False 134 | 135 | def has_delete_permission(self, request, obj=None): 136 | return False 137 | 138 | def has_module_permission(self, request): 139 | return self.has_view_permission(request) 140 | 141 | def has_auditor_change_permission(self, request): 142 | return request.user.has_perms(['auth.change_user', 'auth.change_group']) 143 | 144 | def _get_obj_multiple_exist_redirect(self, request, permission): 145 | """ 146 | Create a message informing the user that multiple permissions were found 147 | for the specified permission string, and return to the admin index page. 148 | """ 149 | msg = _('Found multiple permissions when looking up “%(permission)s”. ' 150 | 'Please ensure only a single permission exists with this name.') % { 151 | 'permission': permission 152 | } 153 | self.message_user(request, msg, messages.WARNING) 154 | url = reverse('admin:index', current_app=self.admin_site.name) 155 | return HttpResponseRedirect(url) 156 | 157 | 158 | class AuditorGroupAdmin(GroupAdmin): 159 | list_display = ['name', 'permissions_display', 'users_display'] 160 | 161 | def permissions_display(self, obj): 162 | result = '' 163 | for perm in obj.permissions.all(): 164 | perm_str = '{}.{}'.format(perm.content_type.app_label, perm.codename) 165 | url = reverse('admin:permissions_auditor_view_permissiondetail', args=(perm_str,)) 166 | result += '{}
'.format(url, perm_str) 167 | return mark_safe(result) 168 | 169 | permissions_display.short_description = 'Permissions' 170 | 171 | def users_display(self, obj): 172 | result = '' 173 | for user in obj.active_users: 174 | url = reverse( 175 | 'admin:{}_{}_change'.format(user._meta.app_label, user._meta.model_name), 176 | args=(user.pk,) 177 | ) 178 | result += '{}
'.format(url, user) 179 | return mark_safe(result) 180 | 181 | users_display.short_description = 'Active Users' 182 | 183 | def get_queryset(self, request): 184 | qs = super().get_queryset(request) 185 | return qs.prefetch_related( 186 | 'permissions', 187 | 'permissions__content_type', 188 | Prefetch( 189 | 'user_set', 190 | queryset=get_user_model()._default_manager.filter(is_active=True), 191 | to_attr='active_users' 192 | ) 193 | ) 194 | 195 | 196 | if _get_setting('PERMISSIONS_AUDITOR_ADMIN'): 197 | admin.site.register(View, ViewsIndexAdmin) 198 | 199 | if _get_setting('PERMISSIONS_AUDITOR_ADMIN_OVERRIDE_GROUPS'): 200 | admin.site.unregister(Group) 201 | admin.site.register(Group, AuditorGroupAdmin) 202 | -------------------------------------------------------------------------------- /permissions_auditor/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.core.cache import cache 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from permissions_auditor.core import _get_setting 6 | 7 | 8 | class PermissionsAuditorConfig(AppConfig): 9 | name = 'permissions_auditor' 10 | verbose_name = _('Permissions Auditor') 11 | 12 | default_auto_field = 'django.db.models.BigAutoField' 13 | 14 | def ready(self): 15 | # Delete the cached views list on application reload. 16 | cache.delete_many([ 17 | _get_setting('PERMISSIONS_AUDITOR_CACHE_KEY'), 18 | _get_setting('PERMISSIONS_AUDITOR_CACHE_KEY') + '_BASE_URL', 19 | ]) 20 | -------------------------------------------------------------------------------- /permissions_auditor/core.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from django.conf import ImproperlyConfigured, settings 4 | from django.contrib.admindocs.views import simplify_regex 5 | from django.core.cache import cache 6 | from django.urls.resolvers import RegexPattern, RoutePattern, URLPattern, URLResolver 7 | from django.utils.module_loading import import_string 8 | 9 | from . import defaults 10 | 11 | 12 | def _get_setting(name): 13 | return getattr(settings, name, getattr(defaults, name)) 14 | 15 | 16 | def _get_blacklist(name): 17 | blacklist = _get_setting('PERMISSIONS_AUDITOR_BLACKLIST') 18 | 19 | # Fall back to the defauls if the user does not provide the specific 20 | # blacklist in their settings. 21 | if name not in blacklist: 22 | blacklist = getattr(defaults, 'PERMISSIONS_AUDITOR_BLACKLIST') 23 | 24 | return blacklist[name] 25 | 26 | 27 | NAMESPACE_BLACKLIST = tuple(_get_blacklist('namespaces')) 28 | VIEW_BLACKLIST = tuple(_get_blacklist('view_names')) 29 | MODULE_BLACKLIST = tuple(_get_blacklist('modules')) 30 | 31 | ViewDetails = namedtuple('ViewDetails', [ 32 | 'module', 'name', 'url', 'permissions', 'login_required', 'docstring' 33 | ]) 34 | 35 | 36 | class ViewParser: 37 | 38 | def __init__(self): 39 | self.load_processors() 40 | 41 | def load_processors(self): 42 | self._processors = [] 43 | 44 | for processor_path in _get_setting('PERMISSIONS_AUDITOR_PROCESSORS'): 45 | 46 | try: 47 | processor = import_string(processor_path) 48 | self._processors.append(processor()) 49 | except (ImportError, TypeError) as ex: 50 | raise ImproperlyConfigured( 51 | '{} is not a valid permissions processor.'.format(processor_path) 52 | ) from ex 53 | 54 | def parse(self, view): 55 | """ 56 | Process a view. 57 | 58 | Returns a tuple containing: 59 | permissions (list), login_required (boolean or None), docstring (str) 60 | """ 61 | permissions = [] 62 | login_required = False 63 | docstrings = [] 64 | 65 | for processor in self._processors: 66 | if processor.can_process(view): 67 | permissions.extend(processor.get_permission_required(view)) 68 | 69 | login_required_result = processor.get_login_required(view) 70 | if login_required_result is None and not login_required: 71 | login_required = login_required_result 72 | else: 73 | login_required = login_required_result or login_required 74 | 75 | docstrings.append(processor.get_docstring(view)) 76 | 77 | return ( 78 | sorted(set(permissions)), 79 | login_required, 80 | '\n'.join(list(set(filter(None, docstrings)))) 81 | ) 82 | 83 | 84 | def _get_views(urlpatterns=None, base_url=''): 85 | """ 86 | Recursively fetch all views in the specified urlpatterns. 87 | 88 | If urlpatterns is not specified, uses the `PERMISSIONS_AUDITOR_ROOT_URLCONF` 89 | setting, which by default is the value of `ROOT_URLCONF` in your project settings. 90 | 91 | Returns a list of `View` namedtuples. 92 | """ 93 | views = [] 94 | 95 | if urlpatterns is None: 96 | root_urlconf = import_string(_get_setting('PERMISSIONS_AUDITOR_ROOT_URLCONF')) 97 | urlpatterns = root_urlconf.urlpatterns 98 | 99 | parser = ViewParser() 100 | 101 | for pattern in urlpatterns: 102 | if isinstance(pattern, RoutePattern) or isinstance(pattern, URLResolver): 103 | 104 | if pattern.namespace in NAMESPACE_BLACKLIST: 105 | continue 106 | 107 | # Recursively fetch patterns 108 | views.extend(_get_views(pattern.url_patterns, base_url + str(pattern.pattern))) 109 | 110 | elif isinstance(pattern, URLPattern) or isinstance(pattern, RegexPattern): 111 | view = pattern.callback 112 | 113 | # If this is a CBV, use the actual class instead of the as_view() classmethod. 114 | view = getattr(view, 'view_class', view) 115 | view = getattr(view, 'cls', view) 116 | 117 | full_view_path = '{}.{}'.format(view.__module__, view.__name__) 118 | if full_view_path in VIEW_BLACKLIST or view.__module__ in MODULE_BLACKLIST: 119 | continue 120 | 121 | permissions, login_required, docstring = parser.parse(view) 122 | 123 | views.append(ViewDetails._make([ 124 | view.__module__, 125 | view.__name__, 126 | simplify_regex(base_url + str(pattern.pattern)), 127 | permissions, 128 | login_required, 129 | docstring 130 | ])) 131 | 132 | return views 133 | 134 | 135 | def get_views(urlpatterns=None, base_url=''): 136 | """ 137 | Wrapper for caching _get_views(). 138 | """ 139 | cache_key = _get_setting('PERMISSIONS_AUDITOR_CACHE_KEY') 140 | cache_base_url_key = cache_key + '_BASE_URL' 141 | cache_timeout = _get_setting('PERMISSIONS_AUDITOR_CACHE_TIMEOUT') 142 | 143 | cache_content = cache.get_many([cache_key, cache_base_url_key]) 144 | views = cache_content.get(cache_key, None) 145 | cached_base_url = cache_content.get(cache_base_url_key, None) 146 | 147 | if views is None or cached_base_url != base_url: 148 | views = _get_views(urlpatterns, base_url) 149 | cache.set_many({ 150 | cache_key: views, 151 | cache_base_url_key: base_url 152 | }, timeout=cache_timeout) 153 | 154 | return views 155 | -------------------------------------------------------------------------------- /permissions_auditor/defaults.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | PERMISSIONS_AUDITOR_PROCESSORS = [ 4 | 'permissions_auditor.processors.auth_mixins.PermissionRequiredMixinProcessor', 5 | 'permissions_auditor.processors.auth_mixins.LoginRequiredMixinProcessor', 6 | 'permissions_auditor.processors.auth_mixins.UserPassesTestMixinProcessor', 7 | 'permissions_auditor.processors.auth_decorators.PermissionRequiredDecoratorProcessor', 8 | 'permissions_auditor.processors.auth_decorators.LoginRequiredDecoratorProcessor', 9 | 'permissions_auditor.processors.auth_decorators.StaffMemberRequiredDecoratorProcessor', 10 | 'permissions_auditor.processors.auth_decorators.SuperUserRequiredDecoratorProcessor', 11 | 'permissions_auditor.processors.auth_decorators.UserPassesTestDecoratorProcessor', 12 | ] 13 | PERMISSIONS_AUDITOR_BLACKLIST = { 14 | 'namespaces': [ 15 | 'admin', 16 | ], 17 | 'view_names': [ 18 | 'django.views.generic.base.RedirectView', 19 | ], 20 | 'modules': [], 21 | } 22 | PERMISSIONS_AUDITOR_ADMIN = True 23 | PERMISSIONS_AUDITOR_ADMIN_OVERRIDE_GROUPS = True 24 | 25 | PERMISSIONS_AUDITOR_ROOT_URLCONF = settings.ROOT_URLCONF 26 | 27 | PERMISSIONS_AUDITOR_CACHE_KEY = 'permissions_auditor_views' 28 | PERMISSIONS_AUDITOR_CACHE_TIMEOUT = 900 29 | -------------------------------------------------------------------------------- /permissions_auditor/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.admin import widgets 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.models import ContentType, Group, Permission 5 | 6 | User = get_user_model() 7 | 8 | 9 | class AuditorAdminPermissionForm(forms.ModelForm): 10 | name = forms.CharField(disabled=True) 11 | content_type = forms.ModelChoiceField(ContentType.objects.all(), disabled=True) 12 | codename = forms.CharField(disabled=True) 13 | users = forms.ModelMultipleChoiceField( 14 | widget=widgets.FilteredSelectMultiple("User", is_stacked=False), 15 | queryset=User.objects.filter(is_active=True), 16 | required=False, 17 | ) 18 | groups = forms.ModelMultipleChoiceField( 19 | widget=widgets.FilteredSelectMultiple("Group", is_stacked=False), 20 | queryset=Group.objects.all(), 21 | required=False, 22 | ) 23 | 24 | class Meta: 25 | model = Permission 26 | fields = ( 27 | 'name', 'content_type', 'codename', 28 | 'users', 'groups', 29 | ) 30 | -------------------------------------------------------------------------------- /permissions_auditor/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/permissions_auditor/management/__init__.py -------------------------------------------------------------------------------- /permissions_auditor/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/permissions_auditor/management/commands/__init__.py -------------------------------------------------------------------------------- /permissions_auditor/management/commands/check_view_permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.core.management.base import BaseCommand 3 | 4 | from permissions_auditor.core import get_views 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Parse all view permissions, and find any that are missing from the database.' 9 | 10 | def get_view_permissions(self): 11 | views = get_views() 12 | permissions = [] 13 | 14 | for view in views: 15 | permissions.extend(view.permissions) 16 | 17 | return list(set(permissions)) 18 | 19 | def get_db_permissions(self): 20 | permissions = Permission.objects.all().values_list('content_type__app_label', 'codename') 21 | return ["{}.{}".format(ct, codename) for ct, codename in permissions] 22 | 23 | def handle(self, *args, **options): 24 | view_permissions = self.get_view_permissions() 25 | db_permissions = self.get_db_permissions() 26 | missing_perms = [] 27 | 28 | for permission in view_permissions: 29 | if permission not in db_permissions: 30 | missing_perms.append(permission) 31 | 32 | if missing_perms: 33 | for permission in missing_perms: 34 | self.stdout.write(self.style.WARNING( 35 | 'Warning: No database entry found for permission `{}`.'.format(permission) 36 | )) 37 | else: 38 | self.stdout.write(self.style.SUCCESS('No permissions without database entries found.')) 39 | -------------------------------------------------------------------------------- /permissions_auditor/management/commands/dump_view_permissions.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from permissions_auditor.core import get_views 7 | 8 | 9 | class Command(BaseCommand): 10 | help = 'Dumps all detected view permissions to the specified output format.' 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | "--format", 15 | default="json", 16 | help="Specifies the output serialization format for permissions. Options: csv, json", 17 | ) 18 | parser.add_argument( 19 | "-o", "--output", help="Specifies file to which the output is written." 20 | ) 21 | 22 | def handle(self, *args, **options): 23 | format = options["format"] 24 | output = options["output"] 25 | views = get_views() 26 | 27 | if format == 'csv': 28 | if output: 29 | with open(output, 'w', newline='') as file: 30 | writer = csv.writer(file, dialect='excel') 31 | self._write_csv(views, writer) 32 | else: 33 | writer = csv.writer(self.stdout) 34 | self._write_csv(views, writer) 35 | 36 | elif format == 'json': 37 | data = [v._asdict() for v in views] 38 | 39 | if output: 40 | with open(output, 'w') as file: 41 | json.dump(data, file, indent=4) 42 | else: 43 | self.stdout.write(json.dumps(data)) 44 | else: 45 | raise NotImplementedError('Output format `{}` is not implemented.'.format(format)) 46 | 47 | def _write_csv(self, views, writer): 48 | # Header 49 | writer.writerow(['module', 'name', 'url', 'permissions', 'login_required', 'docstring']) 50 | 51 | for view in views: 52 | writer.writerow(view) 53 | -------------------------------------------------------------------------------- /permissions_auditor/processors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/permissions_auditor/processors/__init__.py -------------------------------------------------------------------------------- /permissions_auditor/processors/auth_decorators.py: -------------------------------------------------------------------------------- 1 | """Processors for django auth decorators.""" 2 | 3 | import inspect 4 | 5 | from .base import BaseDecoratorProcessor 6 | 7 | 8 | class PermissionRequiredDecoratorProcessor(BaseDecoratorProcessor): 9 | """ 10 | Process ``@permission_required()`` decorator. 11 | """ 12 | 13 | def can_process(self, view): 14 | if inspect.isclass(view): 15 | for func in self._get_method_decorators(view.dispatch): 16 | if 'user_passes_test' not in (func.__name__, func.__qualname__.split('.')[0]): 17 | continue 18 | 19 | test_func = inspect.getclosurevars(func).nonlocals['test_func'] 20 | if test_func.__name__ == 'check_perms': 21 | return True 22 | 23 | elif inspect.isfunction(view): 24 | # Unwrap the function and look for the has_perms property. 25 | return self._has_func_decorator(view, 'has_perms') 26 | 27 | return False 28 | 29 | def get_permission_required(self, view): 30 | permissions = [] 31 | 32 | if inspect.isclass(view): 33 | for func in self._get_method_decorators(view.dispatch): 34 | if 'user_passes_test' not in (func.__name__, func.__qualname__.split('.')[0]): 35 | continue 36 | 37 | test_func = inspect.getclosurevars(func).nonlocals['test_func'] 38 | if test_func.__name__ == 'check_perms': 39 | closures = inspect.getclosurevars(test_func).nonlocals 40 | if 'perm' in closures: 41 | perm = closures['perm'] 42 | 43 | # Ensure perm is not a function 44 | if not inspect.isfunction(perm): 45 | if isinstance(perm, str): 46 | permissions.append(perm) 47 | else: 48 | permissions.extend(perm) 49 | 50 | elif inspect.isfunction(view) and self._has_test_func(view): 51 | for closure in self._get_test_func_closures(view): 52 | if 'perm' in closure.nonlocals: 53 | perm = closure.nonlocals['perm'] 54 | 55 | # Ensure perm is not a function 56 | if not inspect.isfunction(perm): 57 | if isinstance(perm, str): 58 | permissions.append(perm) 59 | else: 60 | permissions.extend(perm) 61 | 62 | return permissions 63 | 64 | def get_login_required(self, view): 65 | return True 66 | 67 | 68 | class LoginRequiredDecoratorProcessor(BaseDecoratorProcessor): 69 | """ 70 | Process ``@login_required`` decorator. 71 | """ 72 | 73 | def can_process(self, view): 74 | if inspect.isclass(view): 75 | return self._has_method_decorator(view.dispatch, 'login_required') 76 | 77 | elif inspect.isfunction(view): 78 | # Unwrap the function and look for the is_authenticated property. 79 | return self._has_func_decorator(view, 'is_authenticated') 80 | 81 | return False 82 | 83 | def get_login_required(self, view): 84 | return True 85 | 86 | 87 | class StaffMemberRequiredDecoratorProcessor(BaseDecoratorProcessor): 88 | """ 89 | Process Django admin's ``@staff_member_required`` decorator. 90 | """ 91 | 92 | def can_process(self, view): 93 | if inspect.isclass(view): 94 | return self._has_method_decorator(view.dispatch, 'staff_member_required') 95 | 96 | elif inspect.isfunction(view): 97 | # Unwrap the function and look for the is_staff property. 98 | return self._has_func_decorator(view, 'is_staff') 99 | 100 | return False 101 | 102 | def get_login_required(self, view): 103 | return True 104 | 105 | def get_docstring(self, view): 106 | return 'Staff member required' 107 | 108 | 109 | class ActiveUserRequiredDecoratorProcessor(BaseDecoratorProcessor): 110 | """ 111 | Process ``@user_passes_test(lambda u: u.is_active)`` decorator. 112 | """ 113 | 114 | def can_process(self, view): 115 | if inspect.isclass(view) and self._has_method_decorator(view.dispatch, 'user_passes_test'): 116 | return self._has_test_func_lambda(view.dispatch, 'is_active') 117 | 118 | elif inspect.isfunction(view): 119 | # Unwrap the function and look for the is_active property. 120 | return self._has_func_decorator(view, 'is_active') 121 | 122 | return False 123 | 124 | def get_login_required(self, view): 125 | return True 126 | 127 | def get_docstring(self, view): 128 | return 'Active user required' 129 | 130 | 131 | class AnonymousUserRequiredDecoratorProcessor(BaseDecoratorProcessor): 132 | """ 133 | Process ``@user_passes_test(lambda u: u.is_anonymous)`` decorator. 134 | """ 135 | 136 | def can_process(self, view): 137 | if inspect.isclass(view) and self._has_method_decorator(view.dispatch, 'user_passes_test'): 138 | return self._has_test_func_lambda(view.dispatch, 'is_anonymous') 139 | 140 | elif inspect.isfunction(view): 141 | # Unwrap the function and look for the is_anonymous property. 142 | return self._has_func_decorator(view, 'is_anonymous') 143 | 144 | return False 145 | 146 | def get_docstring(self, view): 147 | return 'Anonymous user required' 148 | 149 | 150 | class SuperUserRequiredDecoratorProcessor(BaseDecoratorProcessor): 151 | """ 152 | Process ``@user_passes_test(lambda u: u.is_superuser)`` decorator. 153 | """ 154 | 155 | def can_process(self, view): 156 | if inspect.isclass(view) and self._has_method_decorator(view.dispatch, 'user_passes_test'): 157 | return self._has_test_func_lambda(view.dispatch, 'is_superuser') 158 | 159 | elif inspect.isfunction(view): 160 | # Unwrap the function and look for the is_superuser property. 161 | return self._has_func_decorator(view, 'is_superuser') 162 | 163 | return False 164 | 165 | def get_login_required(self, view): 166 | return True 167 | 168 | def get_docstring(self, view): 169 | return 'Superuser required' 170 | 171 | 172 | class UserPassesTestDecoratorProcessor(BaseDecoratorProcessor): 173 | """ 174 | Process ``@user_passes_test()`` decorator. 175 | 176 | .. note:: 177 | the ``@user_passes_test`` decorator does not automatically check 178 | that the User is not anonymous. This means they don't necessarily need 179 | to be authenticated for the check to pass, so this processor returns 180 | ``None`` (unknown) for the login_required attribute. 181 | """ 182 | 183 | def can_process(self, view): 184 | # Some decorators use user_passes_test() internally, so we need to filter 185 | # them out since they are processed elsewhere. 186 | blacklist = ( 187 | 'is_authenticated', 'has_perms', 'is_staff', 'is_active', 'is_anonymous', 188 | 'is_superuser', 189 | ) 190 | 191 | if inspect.isclass(view): 192 | for func in self._get_method_decorators(view.dispatch): 193 | if 'user_passes_test' not in (func.__name__, func.__qualname__.split('.')[0]): 194 | continue 195 | 196 | if not any([self._has_test_func_lambda(func, tag) for tag in blacklist]): 197 | return True 198 | 199 | if inspect.isfunction(view) and self._has_test_func(view): 200 | for closure in self._get_test_func_closures(view): 201 | if not any([tag in closure.unbound for tag in blacklist]): 202 | return True 203 | 204 | return False 205 | 206 | def get_login_required(self, view): 207 | return None 208 | 209 | def get_docstring(self, view): 210 | return 'Custom user test' 211 | -------------------------------------------------------------------------------- /permissions_auditor/processors/auth_mixins.py: -------------------------------------------------------------------------------- 1 | """Processors for django auth mixins.""" 2 | 3 | import inspect 4 | 5 | from django.conf import ImproperlyConfigured 6 | 7 | from .base import BaseFilteredMixinProcessor 8 | 9 | 10 | class PermissionRequiredMixinProcessor(BaseFilteredMixinProcessor): 11 | """ 12 | Processes views that inherit from 13 | ``django.contrib.auth.mixins.PermissionRequiredMixin``. 14 | 15 | .. hint:: 16 | If the ``has_permission()`` function is overridden, any docstrings on that 17 | function will be displayed in the additional info column. 18 | """ 19 | 20 | class_filter = 'django.contrib.auth.mixins.PermissionRequiredMixin' 21 | 22 | def get_permission_required(self, view): 23 | try: 24 | return view().get_permission_required() 25 | except ImproperlyConfigured: 26 | return [] 27 | 28 | def get_login_required(self, view): 29 | return True 30 | 31 | def get_docstring(self, view): 32 | docstring = None 33 | 34 | # Check if has_permission has been overriden. 35 | if 'has_permission' in view.__dict__: 36 | docstring = inspect.getdoc(view.has_permission) 37 | 38 | if docstring is None or docstring.startswith('Override this method'): 39 | docstring = 'Custom (no docstring found)' 40 | 41 | return docstring 42 | 43 | 44 | class LoginRequiredMixinProcessor(BaseFilteredMixinProcessor): 45 | """ 46 | Processes views that inherit from 47 | ``django.contrib.auth.mixins.LoginRequiredMixin``. 48 | """ 49 | 50 | class_filter = 'django.contrib.auth.mixins.LoginRequiredMixin' 51 | 52 | def get_login_required(self, view): 53 | return True 54 | 55 | 56 | class UserPassesTestMixinProcessor(BaseFilteredMixinProcessor): 57 | """ 58 | Processes views that inherit from 59 | ``django.contrib.auth.mixins.UserPassesTestMixin``. 60 | 61 | .. hint:: 62 | If the function returned by ``get_test_func()`` is overridden, any docstrings 63 | on that function will be displayed in the additional info column. 64 | 65 | .. note:: 66 | UserPassesTestMixinProcessor does not automatically check 67 | that the User is not anonymous. This means they don't necessarily need 68 | to be authenticated for the check to pass, so this processor returns 69 | ``None`` (unknown) for the login_required attribute. 70 | """ 71 | 72 | class_filter = 'django.contrib.auth.mixins.UserPassesTestMixin' 73 | 74 | def get_login_required(self, view): 75 | return None 76 | 77 | def get_docstring(self, view): 78 | docstring = inspect.getdoc(view().get_test_func()) 79 | if docstring is None: 80 | docstring = 'Custom (no docstring found)' 81 | return docstring 82 | -------------------------------------------------------------------------------- /permissions_auditor/processors/base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from django.conf import ImproperlyConfigured 4 | 5 | 6 | class BaseProcessor: 7 | 8 | def can_process(self, view): 9 | """ 10 | Can this processor process the provided view? 11 | 12 | :param view: the view being processed. 13 | :type view: function or class 14 | :return: whether this processor can process the view. 15 | Default: ``False`` 16 | :rtype: boolean 17 | """ 18 | return True 19 | 20 | def get_permission_required(self, view): 21 | """ 22 | Returns permissions required on the provided view. 23 | Must return an iterable. 24 | 25 | :param view: the view being processed. 26 | :type view: function or class 27 | :return: the permissions required to access the view. Default: ``[]`` 28 | :rtype: list(str) 29 | """ 30 | return [] 31 | 32 | def get_login_required(self, view): 33 | """ 34 | Returns if a user needs to be logged in to access the view. 35 | 36 | :param view: the view being processed. 37 | :type view: function or class 38 | :return: whether a user must be logged in to access this view. 39 | Default: ``False`` 40 | :rtype: boolean or None (if unknown) 41 | """ 42 | return False 43 | 44 | def get_docstring(self, view): 45 | """ 46 | Returns any additional information that should be displayed when 47 | showing permisison information. 48 | 49 | :param view: the view being processed. 50 | :type view: function or class 51 | :return: the string to display in the additional info column. Default: ``None`` 52 | :rtype: str or None 53 | """ 54 | return None 55 | 56 | 57 | class BaseFuncViewProcessor(BaseProcessor): 58 | """Base class for processing function based views.""" 59 | 60 | def can_process(self, view): 61 | return inspect.isfunction(view) 62 | 63 | 64 | class BaseCBVProcessor(BaseProcessor): 65 | """Base class for processing class based views.""" 66 | 67 | def can_process(self, view): 68 | return inspect.isclass(view) 69 | 70 | 71 | class BaseDecoratorProcessor(BaseProcessor): 72 | """Base class with utilities for unwrapping decorators.""" 73 | 74 | def _has_method_decorator(self, function, func_name): 75 | """ 76 | Checks if a function with the name `func_name` (str) is present within the 77 | ``@method_decorator`` on the provided function. 78 | """ 79 | closures = inspect.getclosurevars(function).nonlocals 80 | if 'decorators' in closures: 81 | for func in closures['decorators']: 82 | if func.__name__ == func_name or func.__qualname__.split('.')[0] == func_name: 83 | return True 84 | 85 | if 'method' in closures: 86 | return self._has_method_decorator(closures['method'], func_name) 87 | 88 | return False 89 | 90 | def _get_method_decorators(self, function): 91 | """ 92 | Returns a generator of functions that decorate the provided function using 93 | ``@method_decorator``. 94 | """ 95 | closures = inspect.getclosurevars(function).nonlocals 96 | if 'decorators' in closures: 97 | for func in closures['decorators']: 98 | yield func 99 | 100 | if 'method' in closures: 101 | yield from self._get_method_decorators(closures['method']) 102 | 103 | def _has_test_func(self, function): 104 | """ 105 | Checks if the provided function is decorated with the ``user_passes_test`` decorator. 106 | """ 107 | closures = inspect.getclosurevars(function).nonlocals 108 | if 'test_func' in closures: 109 | return True 110 | 111 | if 'view_func' in closures: 112 | return self._has_test_func(closures['view_func']) 113 | 114 | return False 115 | 116 | def _has_test_func_lambda(self, function, name): 117 | """ 118 | Checks if the provided function's test_func contains the lambda expression ``name`` (str). 119 | """ 120 | closures = inspect.getclosurevars(function).nonlocals 121 | if 'test_func' in closures: 122 | if name in inspect.getclosurevars(closures['test_func']).unbound: 123 | return True 124 | 125 | if 'decorators' in closures: 126 | for func in closures['decorators']: 127 | if self._has_test_func_lambda(func, name): 128 | return True 129 | 130 | if 'method' in closures: 131 | return self._has_test_func_lambda(closures['method'], name) 132 | 133 | return False 134 | 135 | def _get_test_func_closures(self, function): 136 | closures = inspect.getclosurevars(function).nonlocals 137 | if 'test_func' in closures: 138 | yield inspect.getclosurevars(closures['test_func']) 139 | 140 | if 'view_func' in closures: 141 | yield from self._get_test_func_closures(closures['view_func']) 142 | 143 | def _has_func_decorator(self, function, func_name): 144 | closures = inspect.getclosurevars(function).nonlocals 145 | if 'test_func' in closures: 146 | test_closures = inspect.getclosurevars(closures['test_func']).unbound 147 | if func_name in test_closures: 148 | return True 149 | 150 | if 'view_func' in closures: 151 | return self._has_func_decorator(closures['view_func'], func_name) 152 | 153 | return False 154 | 155 | 156 | class BaseFilteredMixinProcessor(BaseCBVProcessor): 157 | """ 158 | Base class for parsing mixins on class based views. 159 | Set ``class_filter`` to filter the class names the processor applies to. 160 | ONLY checks top level base classes. 161 | 162 | :var class_filter: initial value: ``None`` 163 | """ 164 | class_filter = None 165 | 166 | def can_process(self, view): 167 | if not super().can_process(view): 168 | return False 169 | 170 | view_parents = [cls.__module__ + '.' + cls.__name__ for cls in view.__mro__] 171 | 172 | for cls_filter in self.get_class_filter(): 173 | if cls_filter in view_parents: 174 | return True 175 | 176 | return False 177 | 178 | def get_class_filter(self): 179 | """ 180 | Override this method to override the class_names attribute. 181 | Must return an iterable. 182 | 183 | :return: a list of strings containing the full paths of mixins to detect. 184 | :raises ImproperlyConfigured: if the ``class_filter`` atribute is ``None``. 185 | """ 186 | if self.class_filter is None: 187 | raise ImproperlyConfigured( 188 | '{0} is missing the class_filter attribute. Define {0}.class_filter, or override ' 189 | '{0}.get_class_filter().'.format(self.__class__.__name__) 190 | ) 191 | if isinstance(self.class_filter, str): 192 | cls_filter = (self.class_filter, ) 193 | else: 194 | cls_filter = self.class_filter 195 | return cls_filter 196 | -------------------------------------------------------------------------------- /permissions_auditor/templates/permissions_auditor/admin/permission_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_modify %} 3 | 4 | {% block extrastyle %} 5 | {{ block.super }} 6 | 7 | 8 | 18 | 19 | {{ media }} 20 | {% endblock %} 21 | 22 | {% block title %}{% trans 'Permissions Auditor' %} | {% trans 'Django site admin' %}{% endblock %} 23 | 24 | {% block coltype %}colM{% endblock %} 25 | 26 | {% block breadcrumbs %} 27 | 32 | {% endblock %} 33 | 34 | {% block content %} 35 | {% regroup views|dictsort:"module" by permissions as views_list %} 36 | 37 |

{{ permission }}

38 | 39 |
40 |
41 |

{% trans 'Views with this Permission' %}

42 | 43 | 44 | 45 | 46 | 49 | 52 | 55 | 58 | 61 | 62 | 63 | 64 | 65 | {% for grouper, view_list in views_list %} 66 | {% if permission in grouper %} 67 | {% for view in view_list %} 68 | 69 | 70 | 71 | 76 | 85 | 86 | 87 | {% endfor %}{% resetcycle %} 88 | {% endif %} 89 | {% endfor %} 90 | 91 |
47 |
{% trans 'Name' %}
48 |
50 |
{% trans 'URL' %}
51 |
53 |
{% trans 'Permission Required' %}
54 |
59 |
{% trans 'Additional Info' %}
60 |
{{ view.name }}{{ view.url }} 72 | {% for perm in view.permissions %} 73 | {{ perm }}
74 | {% endfor %} 75 |
77 | {% if view.login_required == None %} 78 | unknown 79 | {% elif view.login_required %} 80 | yes 81 | {% else %} 82 | no 83 | {% endif %} 84 | {{ view.docstring|safe|linebreaks }}
92 |
93 | 94 |
{% csrf_token %}{% block form_top %}{% endblock %} 95 |
96 | {% if is_popup %}{% endif %} 97 | {% if to_field %}{% endif %} 98 | {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} 99 | {% if errors %} 100 |

101 | {% if errors|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} 102 |

103 | {{ adminform.form.non_field_errors }} 104 | {% endif %} 105 | 106 | {% block field_sets %} 107 | {% for fieldset in adminform %} 108 | {% include "admin/includes/fieldset.html" %} 109 | {% endfor %} 110 | {% endblock %} 111 | 112 | {% block after_field_sets %}{% endblock %} 113 | 114 | {% block inline_field_sets %} 115 | {% for inline_admin_formset in inline_admin_formsets %} 116 | {% include inline_admin_formset.opts.template %} 117 | {% endfor %} 118 | {% endblock %} 119 | 120 | {% block after_related_objects %}{% endblock %} 121 | 122 | {% block submit_buttons_bottom %}{% submit_row %}{% endblock %} 123 | 124 | {% block admin_change_form_document_ready %} 125 | 132 | {% endblock %} 133 | 134 | {# JavaScript for prepopulated fields #} 135 | {% prepopulated_fields_js %} 136 | 137 |
138 |
139 |
140 | {% endblock %} 141 | -------------------------------------------------------------------------------- /permissions_auditor/templates/permissions_auditor/admin/views_index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_list %} 3 | 4 | {% block extrastyle %} 5 | {{ block.super }} 6 | 7 | {{ media.css }} 8 | 18 | {% endblock %} 19 | 20 | {% block title %}{% trans 'Permissions Auditor Views' %} | {% trans 'Django site admin' %}{% endblock %} 21 | 22 | {% block breadcrumbs %} 23 | 28 | {% endblock %} 29 | 30 | {% block content %} 31 | 32 | {% if group_by != 'none' %} 33 |

{% trans 'Views By' %} {{ group_by|title }}

34 | {% else %} 35 |

{% trans 'Views' %}

36 | {% endif %} 37 | 38 | {% if group_by == 'module' %} 39 | {% regroup views|dictsort:"module" by module as views_list %} 40 | {% elif group_by == 'permission' %} 41 | {% regroup views|dictsort:"permissions" by permissions as views_list %} 42 | {% else %} 43 | {% regroup views|dictsort:"url" by None as views_list %} 44 | {% endif %} 45 | 46 |
47 |
48 |
49 |

{% trans 'Filter' %}

50 |

{% trans 'Group By' %}

51 | 62 | 63 | {% if group_by != 'none' %} 64 |

{% trans 'Navigate' %}

65 | 82 | {% endif %} 83 |
84 | 85 |
86 | {% for grouper, view_list in views_list %} 87 | {% if group_by == 'module' %} 88 |

{{ grouper }}

89 | {% elif group_by == 'permission' %} 90 |

91 | {% for permission in grouper %} 92 | {{ permission }}{% if not forloop.last %} + {% endif %} 93 | {% empty %} 94 | {% trans 'No permission' %} 95 | {% endfor %} 96 |

97 | {% else %} 98 |

 

99 | {% endif %} 100 | 101 | 102 | 103 | 104 | 107 | 110 | 113 | 116 | 119 | 120 | 121 | 122 | {% for view in view_list %} 123 | 124 | 125 | 126 | 133 | 142 | 143 | 144 | {% endfor %}{% resetcycle %} 145 | 146 |
105 |
{% trans 'Name' %}
106 |
108 |
{% trans 'URL' %}
109 |
111 |
{% trans 'Permission Required' %}
112 |
117 |
{% trans 'Additional Info' %}
118 |
{{ view.name }}{{ view.url }} 127 | {% for permission in view.permissions %} 128 | 129 | {{ permission }} 130 |
131 | {% endfor %} 132 |
134 | {% if view.login_required == None %} 135 | unknown 136 | {% elif view.login_required %} 137 | yes 138 | {% else %} 139 | no 140 | {% endif %} 141 | {{ view.docstring|safe|linebreaks }}
147 | {% endfor %} 148 |
149 |
150 |
151 | {% endblock %} 152 | -------------------------------------------------------------------------------- /permissions_auditor/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/permissions_auditor/tests/__init__.py -------------------------------------------------------------------------------- /permissions_auditor/tests/base.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase 2 | 3 | 4 | class ProcessorTestCase(SimpleTestCase): 5 | processor = None 6 | 7 | def assertCannotProcess(self, views): 8 | for view in views: 9 | self.assertFalse(self.processor.can_process(view)) 10 | 11 | def assertCanProcessView(self, view, permissions=[], login_required=False, docstring=None): 12 | self.assertTrue(self.processor.can_process(view)) 13 | self.assertCountEqual(self.processor.get_permission_required(view), permissions) 14 | self.assertEqual(self.processor.get_login_required(view), login_required) 15 | self.assertEqual(self.processor.get_docstring(view), docstring) 16 | -------------------------------------------------------------------------------- /permissions_auditor/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AACEngineering/django-permissions-auditor/ee3c96ecafa8216b21fb274ffb66b4e3864e867b/permissions_auditor/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /permissions_auditor/tests/fixtures/base_views.py: -------------------------------------------------------------------------------- 1 | """Views used for testing.""" 2 | from django.views.generic import View 3 | 4 | 5 | class BaseView(View): 6 | pass 7 | 8 | 9 | def base_view(request): 10 | pass 11 | -------------------------------------------------------------------------------- /permissions_auditor/tests/fixtures/decorator_views.py: -------------------------------------------------------------------------------- 1 | """Views used for decorator testing.""" 2 | from django.contrib.admin.views.decorators import staff_member_required 3 | from django.contrib.auth.decorators import ( 4 | login_required, permission_required, user_passes_test 5 | ) 6 | from django.utils.decorators import method_decorator 7 | from django.views.generic import View 8 | 9 | 10 | @login_required 11 | def login_required_view(request): 12 | pass 13 | 14 | 15 | class LoginRequiredMethodDecoratorView(View): 16 | @method_decorator(login_required) 17 | def dispatch(self, request, *args, **kwargs): 18 | return super().dispatch(request, *args, **kwargs) 19 | 20 | 21 | @permission_required('tests.test_perm') 22 | def permission_required_view(request): 23 | pass 24 | 25 | 26 | @permission_required(('tests.test_perm', 'tests.test_perm2')) 27 | def permission_required_multi_view(request): 28 | pass 29 | 30 | 31 | class PermissionRequiredMethodDecoratorView(View): 32 | @method_decorator(permission_required('tests.test_perm')) 33 | def dispatch(self, request, *args, **kwargs): 34 | return super().dispatch(request, *args, **kwargs) 35 | 36 | 37 | @staff_member_required 38 | def staff_member_required_view(request): 39 | pass 40 | 41 | 42 | class StaffMemberRequiredMethodDecoratorView(View): 43 | @method_decorator(staff_member_required) 44 | def dispatch(self, request, *args, **kwargs): 45 | return super().dispatch(request, *args, **kwargs) 46 | 47 | 48 | @user_passes_test(lambda u: u.is_active) 49 | def active_user_required_view(request): 50 | pass 51 | 52 | 53 | class ActiveUserRequiredMethodDecoratorView(View): 54 | @method_decorator(user_passes_test(lambda u: u.is_active)) 55 | def dispatch(self, request, *args, **kwargs): 56 | return super().dispatch(request, *args, **kwargs) 57 | 58 | 59 | @user_passes_test(lambda u: u.is_anonymous) 60 | def anonymous_user_required_view(request): 61 | pass 62 | 63 | 64 | class AnonymousUserRequiredMethodDecoratorView(View): 65 | @method_decorator(user_passes_test(lambda u: u.is_anonymous)) 66 | def dispatch(self, request, *args, **kwargs): 67 | return super().dispatch(request, *args, **kwargs) 68 | 69 | 70 | @user_passes_test(lambda u: u.is_superuser) 71 | def superuser_required_view(request): 72 | pass 73 | 74 | 75 | class SuperUserRequiredMethodDecoratorView(View): 76 | @method_decorator(user_passes_test(lambda u: u.is_superuser)) 77 | def dispatch(self, request, *args, **kwargs): 78 | return super().dispatch(request, *args, **kwargs) 79 | 80 | 81 | @user_passes_test(lambda u: u.email is not None) 82 | def user_passes_test_view(request): 83 | pass 84 | 85 | 86 | class UserPassesTestMethodDecoratorView(View): 87 | @method_decorator(user_passes_test(lambda u: u.email is not None)) 88 | def dispatch(self, request, *args, **kwargs): 89 | return super().dispatch(request, *args, **kwargs) 90 | 91 | 92 | @login_required 93 | @user_passes_test(lambda u: u.is_active) 94 | @user_passes_test(lambda u: u.email is not None) 95 | def nested_decorator_view(request): 96 | pass 97 | 98 | 99 | class NestedMethodDecoratorView(View): 100 | @method_decorator(login_required) 101 | @method_decorator(user_passes_test(lambda u: u.is_active)) 102 | @method_decorator(user_passes_test(lambda u: u.email is not None)) 103 | def dispatch(self, request, *args, **kwargs): 104 | pass 105 | -------------------------------------------------------------------------------- /permissions_auditor/tests/fixtures/mixin_views.py: -------------------------------------------------------------------------------- 1 | """Views used for testing.""" 2 | from django.contrib.auth.mixins import ( 3 | LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin 4 | ) 5 | from django.views.generic import View 6 | 7 | 8 | class LoginRequiredView(LoginRequiredMixin, View): 9 | pass 10 | 11 | 12 | class PermissionRequiredView(PermissionRequiredMixin, View): 13 | permission_required = 'tests.test_perm' 14 | 15 | 16 | class PermissionRequiredMultiView(PermissionRequiredMixin, View): 17 | permission_required = ('tests.test_perm', 'tests.test_perm2') 18 | 19 | 20 | class PermissionRequiredViewNoPerm(PermissionRequiredMixin, View): 21 | 22 | def has_permission(self): 23 | """The user's first name must be Bob""" 24 | return self.request.user.first_name == 'Bob' 25 | 26 | 27 | class PermissionRequiredViewDocstring(PermissionRequiredMixin, View): 28 | permission_required = 'tests.test_perm' 29 | 30 | def has_permission(self): 31 | """Custom docstrings should be detected.""" 32 | return super().has_permission() 33 | 34 | 35 | class PermissionRequiredViewNoDocstring(PermissionRequiredMixin, View): 36 | permission_required = 'tests.test_perm' 37 | 38 | def has_permission(self): 39 | return super().has_permission() 40 | 41 | 42 | class UserPassesTestView(UserPassesTestMixin, View): 43 | def test_func(self): 44 | return True 45 | 46 | 47 | class UserPassesTestViewDocstring(UserPassesTestMixin, View): 48 | def test_func(self): 49 | """Custom docstrings should be detected.""" 50 | return True 51 | 52 | 53 | class UserPassesTestViewNoDocstring(UserPassesTestMixin, View): 54 | def test_func(self): 55 | return True 56 | 57 | 58 | class UserPassesTestViewCustomFunc(UserPassesTestMixin, View): 59 | def get_test_func(self): 60 | return self.custom_test_func 61 | 62 | def custom_test_func(self): 63 | """Custom docstrings should be detected.""" 64 | return True 65 | -------------------------------------------------------------------------------- /permissions_auditor/tests/fixtures/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django import VERSION as DJANGO_VERSION 3 | 4 | if DJANGO_VERSION < (4, 0): 5 | # This can be removed once Django 3.2 and below support is dropped. 6 | from django.conf.urls import url, include as old_include 7 | 8 | from django.urls import path, include, re_path 9 | 10 | from . import views 11 | 12 | new_style_urls = ([ 13 | path('login_required/', views.login_required_view), 14 | re_path(r'^perm_required/$', views.permission_required_view), 15 | ], 'new_style_urls') 16 | 17 | admin_namespace = ([ 18 | path('staff_member_required/', views.staff_member_required_view), 19 | ], 'admin') 20 | 21 | urlpatterns = [ 22 | path('', views.BaseView.as_view()), 23 | path('multi_perm_view/', views.PermissionRequiredMultiView.as_view()), 24 | 25 | path('new_style/', include(new_style_urls)), 26 | path('admin/', include(admin_namespace)), 27 | ] 28 | 29 | if DJANGO_VERSION < (4, 0): 30 | # This can be removed once Django 3.2 and below support is dropped. 31 | old_style_urls = ([ 32 | url(r'^login_required/$', views.LoginRequiredView.as_view()), 33 | url(r'^perm_required/$', views.PermissionRequiredView.as_view()), 34 | ], 'old_style_urls') 35 | 36 | urlpatterns += [url('old_style/', old_include(old_style_urls))] 37 | else: 38 | old_style_urls = ([ 39 | re_path(r'^login_required/$', views.LoginRequiredView.as_view()), 40 | re_path(r'^perm_required/$', views.PermissionRequiredView.as_view()), 41 | ], 'old_style_urls') 42 | 43 | urlpatterns += [path('old_style/', include(old_style_urls))] 44 | -------------------------------------------------------------------------------- /permissions_auditor/tests/fixtures/views.py: -------------------------------------------------------------------------------- 1 | """Views used for testing.""" 2 | from django.contrib.admin.views.decorators import staff_member_required 3 | from django.contrib.auth.decorators import ( 4 | login_required, permission_required, user_passes_test 5 | ) 6 | from django.contrib.auth.mixins import ( 7 | LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin 8 | ) 9 | from django.utils.decorators import method_decorator 10 | from django.views.generic import View 11 | 12 | 13 | # Class Based Views 14 | 15 | 16 | class BaseView(View): 17 | pass 18 | 19 | 20 | class LoginRequiredView(LoginRequiredMixin, View): 21 | pass 22 | 23 | 24 | class InheritedLoginRequiredView(LoginRequiredView): 25 | pass 26 | 27 | 28 | class LoginRequiredMethodDecoratorView(View): 29 | @method_decorator(login_required) 30 | def dispatch(self, request, *args, **kwargs): 31 | return super().dispatch(request, *args, **kwargs) 32 | 33 | 34 | class PermissionRequiredView(PermissionRequiredMixin, View): 35 | permission_required = 'tests.test_perm' 36 | 37 | 38 | class InheritedPermissionRequiredView(PermissionRequiredView): 39 | pass 40 | 41 | 42 | class PermissionRequiredMultiView(PermissionRequiredMixin, View): 43 | permission_required = ('tests.test_perm', 'tests.test_perm2') 44 | 45 | 46 | class PermissionRequiredViewNoPerm(PermissionRequiredMixin, View): 47 | 48 | def has_permission(self): 49 | """The user's first name must be Bob""" 50 | return self.request.user.first_name == 'Bob' 51 | 52 | 53 | class PermissionRequiredViewDocstring(PermissionRequiredMixin, View): 54 | permission_required = 'tests.test_perm' 55 | 56 | def has_permission(self): 57 | """Custom docstrings should be detected.""" 58 | return super().has_permission() 59 | 60 | 61 | class PermissionRequiredViewNoDocstring(PermissionRequiredMixin, View): 62 | permission_required = 'tests.test_perm' 63 | 64 | def has_permission(self): 65 | return super().has_permission() 66 | 67 | 68 | class PermissionRequiredMethodDecoratorView(View): 69 | @method_decorator(permission_required('tests.test_perm')) 70 | def dispatch(self, request, *args, **kwargs): 71 | return super().dispatch(request, *args, **kwargs) 72 | 73 | 74 | class StaffMemberRequiredMethodDecoratorView(View): 75 | @method_decorator(staff_member_required) 76 | def dispatch(self, request, *args, **kwargs): 77 | return super().dispatch(request, *args, **kwargs) 78 | 79 | 80 | class UserPassesTestView(UserPassesTestMixin, View): 81 | def test_func(self): 82 | return True 83 | 84 | 85 | class InheritedUserPassesTestView(UserPassesTestView): 86 | def test_func(self): 87 | return True 88 | 89 | 90 | class UserPassesTestViewDocstring(UserPassesTestMixin, View): 91 | def test_func(self): 92 | """Custom docstrings should be detected.""" 93 | return True 94 | 95 | 96 | class UserPassesTestViewNoDocstring(UserPassesTestMixin, View): 97 | def test_func(self): 98 | return True 99 | 100 | 101 | class UserPassesTestViewCustomFunc(UserPassesTestMixin, View): 102 | def get_test_func(self): 103 | return self.custom_test_func 104 | 105 | def custom_test_func(self): 106 | """Custom docstrings should be detected.""" 107 | return True 108 | 109 | 110 | # Function Based Views 111 | 112 | 113 | def base_view(request): 114 | pass 115 | 116 | 117 | @login_required 118 | def login_required_view(request): 119 | pass 120 | 121 | 122 | @permission_required('tests.test_perm') 123 | def permission_required_view(request): 124 | pass 125 | 126 | 127 | @permission_required(('tests.test_perm', 'tests.test_perm2')) 128 | def permission_required_multi_view(request): 129 | pass 130 | 131 | 132 | @staff_member_required 133 | def staff_member_required_view(request): 134 | pass 135 | 136 | 137 | @user_passes_test(lambda u: u.is_active) 138 | def active_user_required_view(request): 139 | pass 140 | 141 | 142 | @user_passes_test(lambda u: u.is_anonymous) 143 | def anonymous_user_required_view(request): 144 | pass 145 | 146 | 147 | @user_passes_test(lambda u: u.is_superuser) 148 | def superuser_required_view(request): 149 | pass 150 | 151 | 152 | @user_passes_test(lambda u: u.email is not None) 153 | def user_passes_test_view(request): 154 | pass 155 | 156 | 157 | @user_passes_test(lambda u: u.email is not None) 158 | @login_required 159 | def nested_decorator_view(request): 160 | pass 161 | -------------------------------------------------------------------------------- /permissions_auditor/tests/test_auth_decorator_processors.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import PasswordChangeView 2 | 3 | from permissions_auditor.processors import auth_decorators 4 | from permissions_auditor.tests.base import ProcessorTestCase 5 | from permissions_auditor.tests.fixtures import ( 6 | base_views, decorator_views as views, mixin_views 7 | ) 8 | 9 | 10 | class DecoratorProcessorTestCaseMixin: 11 | """ 12 | Decorator processors should not be able to process class based views 13 | that do not use ``@method_decorator``. 14 | """ 15 | 16 | def test_cannot_process_non_method_decorator_cbvs(self): 17 | self.assertCannotProcess([ 18 | base_views.BaseView, base_views.base_view, 19 | mixin_views.LoginRequiredView, 20 | mixin_views.PermissionRequiredView, 21 | mixin_views.UserPassesTestView 22 | ]) 23 | 24 | 25 | class TestLoginRequiredDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 26 | 27 | def setUp(self): 28 | self.processor = auth_decorators.LoginRequiredDecoratorProcessor() 29 | self.expected_results = {'login_required': True} 30 | 31 | def test_cannot_process(self): 32 | self.assertCannotProcess([ 33 | views.permission_required_view, views.PermissionRequiredMethodDecoratorView, 34 | views.staff_member_required_view, views.StaffMemberRequiredMethodDecoratorView, 35 | views.active_user_required_view, views.ActiveUserRequiredMethodDecoratorView, 36 | views.anonymous_user_required_view, views.AnonymousUserRequiredMethodDecoratorView, 37 | views.superuser_required_view, views.SuperUserRequiredMethodDecoratorView, 38 | views.user_passes_test_view, views.UserPassesTestMethodDecoratorView 39 | ]) 40 | 41 | def test_django_password_change_view(self): 42 | self.assertCanProcessView(PasswordChangeView, **self.expected_results) 43 | 44 | def test_login_required_view(self): 45 | self.assertCanProcessView(views.login_required_view, **self.expected_results) 46 | 47 | def test_cb_loginrequired_decorator_view(self): 48 | self.assertCanProcessView(views.LoginRequiredMethodDecoratorView, **self.expected_results) 49 | 50 | def test_nested_decorator_view(self): 51 | self.assertCanProcessView(views.nested_decorator_view, **self.expected_results) 52 | 53 | def test_nested_decorator_cbv(self): 54 | self.assertCanProcessView(views.NestedMethodDecoratorView, **self.expected_results) 55 | 56 | 57 | class TestPermissionRequiredDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 58 | 59 | def setUp(self): 60 | self.processor = auth_decorators.PermissionRequiredDecoratorProcessor() 61 | self.expected_results = {'permissions': ['tests.test_perm'], 'login_required': True} 62 | 63 | def test_cannot_process(self): 64 | self.assertCannotProcess([ 65 | views.login_required_view, views.LoginRequiredMethodDecoratorView, 66 | views.staff_member_required_view, views.StaffMemberRequiredMethodDecoratorView, 67 | views.active_user_required_view, views.ActiveUserRequiredMethodDecoratorView, 68 | views.anonymous_user_required_view, views.AnonymousUserRequiredMethodDecoratorView, 69 | views.superuser_required_view, views.SuperUserRequiredMethodDecoratorView, 70 | views.user_passes_test_view, views.UserPassesTestMethodDecoratorView, 71 | views.nested_decorator_view, views.NestedMethodDecoratorView 72 | ]) 73 | 74 | def test_permission_required_view(self): 75 | self.assertCanProcessView( 76 | views.PermissionRequiredMethodDecoratorView, **self.expected_results 77 | ) 78 | 79 | def test_permission_required_cbv(self): 80 | self.assertCanProcessView(views.permission_required_view, **self.expected_results) 81 | 82 | def test_permission_required_multi_view(self): 83 | """Multiple permissions passed to @permission_required should be retrieved.""" 84 | self.assertCanProcessView( 85 | views.permission_required_multi_view, 86 | permissions=['tests.test_perm', 'tests.test_perm2'], login_required=True, 87 | ) 88 | 89 | 90 | class StaffMemberRequiredDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 91 | 92 | def setUp(self): 93 | self.processor = auth_decorators.StaffMemberRequiredDecoratorProcessor() 94 | self.expected_results = {'login_required': True, 'docstring': 'Staff member required'} 95 | 96 | def test_cannot_process(self): 97 | self.assertCannotProcess([ 98 | views.login_required_view, views.LoginRequiredMethodDecoratorView, 99 | views.permission_required_view, views.PermissionRequiredMethodDecoratorView, 100 | views.active_user_required_view, views.ActiveUserRequiredMethodDecoratorView, 101 | views.anonymous_user_required_view, views.AnonymousUserRequiredMethodDecoratorView, 102 | views.superuser_required_view, views.SuperUserRequiredMethodDecoratorView, 103 | views.user_passes_test_view, views.UserPassesTestMethodDecoratorView, 104 | views.nested_decorator_view, views.NestedMethodDecoratorView 105 | ]) 106 | 107 | def test_staff_member_required_view(self): 108 | self.assertCanProcessView(views.staff_member_required_view, **self.expected_results) 109 | 110 | def test_staff_member_required_cb_view(self): 111 | self.assertCanProcessView( 112 | views.StaffMemberRequiredMethodDecoratorView, **self.expected_results 113 | ) 114 | 115 | 116 | class ActiveUserRequiredDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 117 | 118 | def setUp(self): 119 | self.processor = auth_decorators.ActiveUserRequiredDecoratorProcessor() 120 | self.expected_results = {'login_required': True, 'docstring': 'Active user required'} 121 | 122 | def test_cannot_process(self): 123 | self.assertCannotProcess([ 124 | views.login_required_view, views.LoginRequiredMethodDecoratorView, 125 | views.permission_required_view, views.PermissionRequiredMethodDecoratorView, 126 | views.anonymous_user_required_view, views.AnonymousUserRequiredMethodDecoratorView, 127 | views.superuser_required_view, views.SuperUserRequiredMethodDecoratorView, 128 | views.user_passes_test_view, views.UserPassesTestMethodDecoratorView 129 | ]) 130 | 131 | def test_staff_member_required_view(self): 132 | # Internally, ``staff_member_required`` runs this lamda: 133 | # lambda u: u.is_active and u.is_staff 134 | self.assertCanProcessView(views.staff_member_required_view, **self.expected_results) 135 | 136 | def test_staff_member_required_cbv(self): 137 | # Quirk: when ``staf_member_required`` is wrapped with ``method_decorator``, 138 | # the lamda expression can no longer be found in the function closures, 139 | # so this should return false. 140 | self.assertCannotProcess([views.StaffMemberRequiredMethodDecoratorView]) 141 | 142 | def test_active_user_required_view(self): 143 | self.assertCanProcessView(views.active_user_required_view, **self.expected_results) 144 | 145 | def test_active_user_required_cbv(self): 146 | self.assertCanProcessView( 147 | views.ActiveUserRequiredMethodDecoratorView, **self.expected_results 148 | ) 149 | 150 | def test_nested_decorator_view(self): 151 | self.assertCanProcessView(views.nested_decorator_view, **self.expected_results) 152 | 153 | def test_nested_decorator_cbv(self): 154 | self.assertCanProcessView(views.NestedMethodDecoratorView, **self.expected_results) 155 | 156 | 157 | class AnonymousUserRequiredDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 158 | 159 | def setUp(self): 160 | self.processor = auth_decorators.AnonymousUserRequiredDecoratorProcessor() 161 | self.expected_results = {'login_required': False, 'docstring': 'Anonymous user required'} 162 | 163 | def test_cannot_process(self): 164 | self.assertCannotProcess([ 165 | views.login_required_view, views.LoginRequiredMethodDecoratorView, 166 | views.permission_required_view, views.PermissionRequiredMethodDecoratorView, 167 | views.staff_member_required_view, views.StaffMemberRequiredMethodDecoratorView, 168 | views.active_user_required_view, views.ActiveUserRequiredMethodDecoratorView, 169 | views.superuser_required_view, views.SuperUserRequiredMethodDecoratorView, 170 | views.user_passes_test_view, views.UserPassesTestMethodDecoratorView, 171 | views.nested_decorator_view, views.NestedMethodDecoratorView 172 | ]) 173 | 174 | def test_anonymous_user_required_view(self): 175 | self.assertCanProcessView(views.anonymous_user_required_view, **self.expected_results) 176 | 177 | def test_anonymous_user_required_cbv(self): 178 | self.assertCanProcessView( 179 | views.AnonymousUserRequiredMethodDecoratorView, **self.expected_results 180 | ) 181 | 182 | 183 | class SuperUserRequiredDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 184 | 185 | def setUp(self): 186 | self.processor = auth_decorators.SuperUserRequiredDecoratorProcessor() 187 | self.expected_results = {'login_required': True, 'docstring': 'Superuser required'} 188 | 189 | def test_cannot_process(self): 190 | self.assertCannotProcess([ 191 | views.login_required_view, views.LoginRequiredMethodDecoratorView, 192 | views.permission_required_view, views.PermissionRequiredMethodDecoratorView, 193 | views.staff_member_required_view, views.StaffMemberRequiredMethodDecoratorView, 194 | views.active_user_required_view, views.ActiveUserRequiredMethodDecoratorView, 195 | views.anonymous_user_required_view, views.AnonymousUserRequiredMethodDecoratorView, 196 | views.user_passes_test_view, views.UserPassesTestMethodDecoratorView, 197 | views.nested_decorator_view, views.NestedMethodDecoratorView 198 | ]) 199 | 200 | def test_superuser_required_view(self): 201 | self.assertCanProcessView(views.superuser_required_view, **self.expected_results) 202 | 203 | def test_superuser_required_cbv(self): 204 | self.assertCanProcessView( 205 | views.SuperUserRequiredMethodDecoratorView, **self.expected_results 206 | ) 207 | 208 | 209 | class UserPassesTestDecoratorProcessor(DecoratorProcessorTestCaseMixin, ProcessorTestCase): 210 | 211 | def setUp(self): 212 | self.processor = auth_decorators.UserPassesTestDecoratorProcessor() 213 | self.expected_results = {'login_required': None, 'docstring': 'Custom user test'} 214 | 215 | def test_cannot_process(self): 216 | self.assertCannotProcess([ 217 | views.login_required_view, views.LoginRequiredMethodDecoratorView, 218 | views.permission_required_view, views.PermissionRequiredMethodDecoratorView, 219 | views.staff_member_required_view, views.StaffMemberRequiredMethodDecoratorView, 220 | views.active_user_required_view, views.ActiveUserRequiredMethodDecoratorView, 221 | views.anonymous_user_required_view, views.AnonymousUserRequiredMethodDecoratorView, 222 | views.superuser_required_view, views.SuperUserRequiredMethodDecoratorView, 223 | ]) 224 | 225 | def test_user_passes_test_view(self): 226 | self.assertCanProcessView(views.user_passes_test_view, **self.expected_results) 227 | 228 | def test_user_passes_test_cb_view(self): 229 | self.assertCanProcessView(views.UserPassesTestMethodDecoratorView, **self.expected_results) 230 | 231 | def test_nested_decorator_view(self): 232 | self.assertCanProcessView(views.nested_decorator_view, **self.expected_results) 233 | 234 | def test_nested_decorator_cbv(self): 235 | self.assertCanProcessView(views.NestedMethodDecoratorView, **self.expected_results) 236 | -------------------------------------------------------------------------------- /permissions_auditor/tests/test_auth_mixin_processors.py: -------------------------------------------------------------------------------- 1 | from permissions_auditor.processors import auth_mixins 2 | from permissions_auditor.tests.base import ProcessorTestCase 3 | from permissions_auditor.tests.fixtures import views 4 | 5 | 6 | class MixinProcessorTestCaseMixin: 7 | """Mixins should never be able to process function based views.""" 8 | 9 | def assert_cannot_process_non_cbvs(self): 10 | self.assertCannotProcess([ 11 | views.base_view, views.BaseView, 12 | views.login_required_view, 13 | views.staff_member_required_view, 14 | views.active_user_required_view, 15 | views.anonymous_user_required_view, 16 | views.superuser_required_view, 17 | views.user_passes_test_view 18 | ]) 19 | 20 | 21 | class TestLoginRequiredMixinProcessor(MixinProcessorTestCaseMixin, ProcessorTestCase): 22 | 23 | def setUp(self): 24 | self.processor = auth_mixins.LoginRequiredMixinProcessor() 25 | self.expected_results = {'permissions': [], 'login_required': True, 'docstring': None} 26 | 27 | def test_cannot_process(self): 28 | self.assertCannotProcess([ 29 | views.PermissionRequiredView, views.PermissionRequiredMultiView, 30 | views.PermissionRequiredViewNoPerm, 31 | views.PermissionRequiredViewDocstring, views.PermissionRequiredViewNoDocstring, 32 | views.UserPassesTestView, views.UserPassesTestViewCustomFunc, 33 | views.UserPassesTestViewDocstring, views.UserPassesTestViewNoDocstring 34 | ]) 35 | 36 | def test_cb_loginrequiredview(self): 37 | self.assertCanProcessView(views.LoginRequiredView, **self.expected_results) 38 | 39 | def test_cb_inheritedloginrequiredview(self): 40 | self.assertCanProcessView(views.InheritedLoginRequiredView, **self.expected_results) 41 | 42 | 43 | class TestPermissionRequiredMixinProcessor(MixinProcessorTestCaseMixin, ProcessorTestCase): 44 | 45 | def setUp(self): 46 | self.processor = auth_mixins.PermissionRequiredMixinProcessor() 47 | 48 | def test_cannot_process(self): 49 | self.assertCannotProcess([ 50 | views.LoginRequiredView, 51 | views.UserPassesTestView, views.UserPassesTestViewCustomFunc, 52 | views.UserPassesTestViewDocstring, views.UserPassesTestViewNoDocstring 53 | ]) 54 | 55 | def test_cb_permissionsrequiredview(self): 56 | self.assertCanProcessView( 57 | views.PermissionRequiredView, 58 | permissions=['tests.test_perm'], login_required=True, docstring=None 59 | ) 60 | 61 | def test_cb_inherit_permissionsrequiredview(self): 62 | self.assertCanProcessView( 63 | views.InheritedPermissionRequiredView, 64 | permissions=['tests.test_perm'], login_required=True, docstring=None 65 | ) 66 | 67 | def test_cb_permissionsrequiredview_no_perm(self): 68 | """ 69 | Views that override has_permission() and do not set permission_required should be processed. 70 | """ 71 | self.assertCanProcessView( 72 | views.PermissionRequiredViewNoPerm, 73 | permissions=[], login_required=True, docstring='The user\'s first name must be Bob' 74 | ) 75 | 76 | def test_cb_permissionsrequiredview_docstring(self): 77 | """Views that implement has_permission() and have a docstring should be retrieved.""" 78 | self.assertCanProcessView( 79 | views.PermissionRequiredViewDocstring, 80 | permissions=['tests.test_perm'], login_required=True, 81 | docstring='Custom docstrings should be detected.' 82 | ) 83 | 84 | def test_cb_permissionsrequiredview_no_docstring(self): 85 | """ 86 | Views that implement has_permission() and do not have a docstring 87 | should return a default messsage. 88 | """ 89 | self.assertCanProcessView( 90 | views.PermissionRequiredViewNoDocstring, 91 | permissions=['tests.test_perm'], login_required=True, 92 | docstring='Custom (no docstring found)' 93 | ) 94 | 95 | def test_cb_permissionrequiredview_multi(self): 96 | """ 97 | Views with multiple permissions should return all permissions. 98 | """ 99 | self.assertCanProcessView( 100 | views.PermissionRequiredMultiView, 101 | permissions=['tests.test_perm', 'tests.test_perm2'], login_required=True, docstring=None 102 | ) 103 | 104 | 105 | class TestUserPassesTestMixinProcessor(MixinProcessorTestCaseMixin, ProcessorTestCase): 106 | 107 | def setUp(self): 108 | self.processor = auth_mixins.UserPassesTestMixinProcessor() 109 | 110 | def test_cannot_process(self): 111 | self.assertCannotProcess([ 112 | views.LoginRequiredView, 113 | views.PermissionRequiredView, views.PermissionRequiredMultiView, 114 | views.PermissionRequiredViewNoPerm, 115 | views.PermissionRequiredViewDocstring, views.PermissionRequiredViewNoDocstring 116 | ]) 117 | 118 | def test_cb_userpassestestview(self): 119 | self.assertCanProcessView( 120 | views.UserPassesTestView, 121 | permissions=[], login_required=None, docstring='Custom (no docstring found)' 122 | ) 123 | 124 | def test_cb_inheriteduserpassestestview(self): 125 | self.assertCanProcessView( 126 | views.InheritedUserPassesTestView, 127 | permissions=[], login_required=None, docstring='Custom (no docstring found)' 128 | ) 129 | 130 | def test_cb_userpassestestview_docstring(self): 131 | """Views that implement test_func() and have a docstring should be retrieved.""" 132 | self.assertCanProcessView( 133 | views.UserPassesTestViewDocstring, 134 | permissions=[], login_required=None, docstring='Custom docstrings should be detected.' 135 | ) 136 | 137 | def test_cb_userpassestestview_no_docstring(self): 138 | """ 139 | Views that implement test_func() and do not have a docstring 140 | should return a default messsage. 141 | """ 142 | self.assertCanProcessView( 143 | views.UserPassesTestViewNoDocstring, 144 | permissions=[], login_required=None, docstring='Custom (no docstring found)' 145 | ) 146 | 147 | def test_cb_userpassestestview_custom_func(self): 148 | """ 149 | Views that override get_test_func() should check the new function returned 150 | instead of the default test_func() function. 151 | """ 152 | self.assertCanProcessView( 153 | views.UserPassesTestViewCustomFunc, 154 | permissions=[], login_required=None, docstring='Custom docstrings should be detected.' 155 | ) 156 | -------------------------------------------------------------------------------- /permissions_auditor/tests/test_base_processors.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.conf import ImproperlyConfigured 4 | 5 | from permissions_auditor.processors import base 6 | from permissions_auditor.tests.base import ProcessorTestCase 7 | from permissions_auditor.tests.fixtures import views 8 | 9 | 10 | class BaseProcessorTest(ProcessorTestCase): 11 | def setUp(self): 12 | self.processor = base.BaseProcessor() 13 | 14 | def test_process(self): 15 | self.assertCanProcessView(views.BaseView) 16 | 17 | 18 | class BaseFuncViewProcessorTest(ProcessorTestCase): 19 | def setUp(self): 20 | self.processor = base.BaseFuncViewProcessor() 21 | 22 | def test_can_process_class(self): 23 | self.assertCannotProcess([views.BaseView]) 24 | 25 | def test_can_process_function(self): 26 | self.assertCanProcessView(views.base_view) 27 | 28 | 29 | class BaseCBVProcessorTest(ProcessorTestCase): 30 | def setUp(self): 31 | self.processor = base.BaseCBVProcessor() 32 | 33 | def test_can_process_class(self): 34 | self.assertCanProcessView(views.BaseView) 35 | 36 | def test_can_process_function(self): 37 | self.assertCannotProcess([views.base_view]) 38 | 39 | 40 | class BaseFilteredMixinProcessorTest(ProcessorTestCase): 41 | def setUp(self): 42 | self.processor = base.BaseFilteredMixinProcessor() 43 | 44 | def test_no_class_filter_raises_exception(self): 45 | with self.assertRaises(ImproperlyConfigured): 46 | self.assertCannotProcess([views.PermissionRequiredView]) 47 | 48 | def test_can_process_filtered_class(self): 49 | self.processor.class_filter = 'django.contrib.auth.mixins.PermissionRequiredMixin' 50 | self.assertCanProcessView(views.PermissionRequiredView) 51 | self.assertCannotProcess([views.LoginRequiredView]) 52 | 53 | def test_can_process_multiple_filtered_classses(self): 54 | self.processor.class_filter = ( 55 | 'django.contrib.auth.mixins.PermissionRequiredMixin', 56 | 'django.contrib.auth.mixins.LoginRequiredMixin', 57 | ) 58 | self.assertCanProcessView(views.PermissionRequiredView) 59 | self.assertCanProcessView(views.LoginRequiredView) 60 | 61 | def test_can_process_overriden_filtered_class(self): 62 | """ 63 | can_process() should use use get_class_filter() if it is overridden. 64 | """ 65 | self.processor.class_filter = None 66 | 67 | def filter_func(): 68 | return ('django.contrib.auth.mixins.LoginRequiredMixin',) 69 | 70 | with patch.object(self.processor, 'get_class_filter', filter_func): 71 | self.assertCannotProcess([views.PermissionRequiredView]) 72 | self.assertCanProcessView(views.LoginRequiredView) 73 | -------------------------------------------------------------------------------- /permissions_auditor/tests/test_core.py: -------------------------------------------------------------------------------- 1 | from django.conf import ImproperlyConfigured 2 | from django.test import SimpleTestCase 3 | 4 | from permissions_auditor import core 5 | from permissions_auditor.core import ViewDetails 6 | from permissions_auditor.tests.fixtures import views 7 | 8 | 9 | class ViewParserTest(SimpleTestCase): 10 | 11 | def setUp(self): 12 | self.parser = core.ViewParser() 13 | 14 | def test_invalid_config(self): 15 | """ 16 | Invalid processors should raise ImproperlyConfigured. 17 | """ 18 | with self.settings(PERMISSIONS_AUDITOR_PROCESSORS=['invalid_parser']): 19 | self.assertRaises(ImproperlyConfigured, lambda: core.ViewParser()) 20 | 21 | def test_no_parsers(self): 22 | with self.settings(PERMISSIONS_AUDITOR_PROCESSORS=[]): 23 | parser = core.ViewParser() 24 | results = parser.parse(views.LoginRequiredView) 25 | self.assertEqual(results, ([], False, '')) 26 | 27 | def test_parse_cbv(self): 28 | """ 29 | The default configuration should be able to parse PermissionRequiredMixin. 30 | """ 31 | results = self.parser.parse(views.PermissionRequiredViewDocstring) 32 | self.assertEqual( 33 | results, (['tests.test_perm'], True, 'Custom docstrings should be detected.') 34 | ) 35 | 36 | def test_parse_func_view(self): 37 | """ 38 | The default configuration should be able to parse @permission_required(). 39 | """ 40 | results = self.parser.parse(views.permission_required_view) 41 | self.assertEqual( 42 | results, (['tests.test_perm'], True, '') 43 | ) 44 | 45 | 46 | class GetViewsTest(SimpleTestCase): 47 | 48 | def setUp(self): 49 | self.module = 'permissions_auditor.tests.fixtures.views' 50 | self.views_results = [ 51 | ViewDetails( 52 | module=self.module, name='BaseView', 53 | url='/', 54 | permissions=[], login_required=False, docstring='' 55 | ), 56 | ViewDetails( 57 | module=self.module, name='PermissionRequiredMultiView', 58 | url='/multi_perm_view/', 59 | permissions=['tests.test_perm', 'tests.test_perm2'], 60 | login_required=True, docstring='' 61 | ), 62 | ViewDetails( 63 | module=self.module, name='login_required_view', 64 | url='/new_style/login_required/', 65 | permissions=[], login_required=True, docstring='' 66 | ), 67 | ViewDetails( 68 | module=self.module, name='permission_required_view', 69 | url='/new_style/perm_required/', 70 | permissions=['tests.test_perm'], login_required=True, docstring='' 71 | ), 72 | ViewDetails( 73 | module=self.module, name='staff_member_required_view', 74 | url='/admin/staff_member_required/', 75 | permissions=[], login_required=True, docstring='Staff member required' 76 | ), 77 | ViewDetails( 78 | module=self.module, name='LoginRequiredView', 79 | url='/old_style/login_required/', 80 | permissions=[], login_required=True, docstring='' 81 | ), 82 | ViewDetails( 83 | module=self.module, name='PermissionRequiredView', 84 | url='/old_style/perm_required/', 85 | permissions=['tests.test_perm'], login_required=True, docstring='' 86 | ), 87 | ] 88 | 89 | def reload_blacklist(self): 90 | """ 91 | The blacklist is loaded when the app starts, we need to override the values 92 | when we change settings. 93 | """ 94 | core.NAMESPACE_BLACKLIST = tuple(core._get_blacklist('namespaces')) 95 | core.VIEW_BLACKLIST = tuple(core._get_blacklist('view_names')) 96 | core.MODULE_BLACKLIST = tuple(core._get_blacklist('modules')) 97 | 98 | def test_get_views_results(self): 99 | blacklist = { 100 | 'namespaces': [], 101 | 'view_names': [], 102 | 'modules': [], 103 | } 104 | 105 | with self.settings(PERMISSIONS_AUDITOR_BLACKLIST=blacklist): 106 | self.reload_blacklist() 107 | views = core.get_views() 108 | self.assertSequenceEqual(views, self.views_results) 109 | 110 | def test_namespace_blacklist(self): 111 | blacklist = { 112 | 'namespaces': ['admin'], 113 | 'view_names': [], 114 | 'modules': [], 115 | } 116 | 117 | with self.settings(PERMISSIONS_AUDITOR_BLACKLIST=blacklist): 118 | self.reload_blacklist() 119 | views = core.get_views() 120 | self.assertNotIn(views, self.views_results[3]) # staff_member_required_view 121 | 122 | def test_view_name_blacklist(self): 123 | blacklist = { 124 | 'namespaces': [], 125 | 'view_names': ['{}.BaseView'.format(self.module)], 126 | 'modules': [], 127 | } 128 | 129 | with self.settings(PERMISSIONS_AUDITOR_BLACKLIST=blacklist): 130 | self.reload_blacklist() 131 | views = core.get_views() 132 | self.assertNotIn(views, self.views_results[0]) # BaseView 133 | 134 | def test_module_blacklist(self): 135 | blacklist = { 136 | 'namespaces': [], 137 | 'view_names': [], 138 | 'modules': [self.module], 139 | } 140 | 141 | with self.settings(PERMISSIONS_AUDITOR_BLACKLIST=blacklist): 142 | self.reload_blacklist() 143 | views = core.get_views() 144 | self.assertEqual(views, []) 145 | -------------------------------------------------------------------------------- /permissions_auditor/tests/test_management.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from django.core.management import call_command 3 | from django.test import TestCase 4 | 5 | 6 | class CheckViewPermsTest(TestCase): 7 | 8 | def test_missing_perms(self): 9 | out = StringIO() 10 | call_command('check_view_permissions', stdout=out) 11 | self.assertIn('`tests.test_perm`', out.getvalue()) 12 | self.assertIn('`tests.test_perm2`', out.getvalue()) 13 | 14 | 15 | class CheckDumpViewPermsTest(TestCase): 16 | 17 | def test_dump_perms_no_args(self): 18 | out = StringIO() 19 | call_command('dump_view_permissions', stdout=out) 20 | 21 | # Default output should be JSON formatted 22 | self.assertIn('[{"module": "permissions_auditor.tests.fixtures.views"', out.getvalue()) 23 | 24 | def test_dump_perms_json(self): 25 | out = StringIO() 26 | call_command('dump_view_permissions', format='json', stdout=out) 27 | 28 | # Output should be JSON formatted 29 | self.assertIn('[{"module": "permissions_auditor.tests.fixtures.views"', out.getvalue()) 30 | 31 | def test_dump_perms_csv(self): 32 | out = StringIO() 33 | call_command('dump_view_permissions', format='csv', stdout=out) 34 | 35 | # CSV output header should be present 36 | self.assertIn('module,name,url,permissions,login_required,docstring', out.getvalue()) 37 | -------------------------------------------------------------------------------- /permissions_auditor/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'test-key' 2 | CACHES = { 3 | 'default': { 4 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 5 | } 6 | } 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3' 10 | } 11 | } 12 | MIDDLEWARE = [ 13 | 'django.contrib.sessions.middleware.SessionMiddleware', 14 | 'django.contrib.messages.middleware.MessageMiddleware', 15 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 16 | ] 17 | INSTALLED_APPS = [ 18 | 'django.contrib.admin', 19 | 'django.contrib.auth', 20 | 'django.contrib.contenttypes', 21 | 'django.contrib.messages', 22 | 'django.contrib.sessions', 23 | 'permissions_auditor', 24 | ] 25 | TEMPLATES = [ 26 | { 27 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 28 | 'DIRS': [], 29 | 'APP_DIRS': True, 30 | 'OPTIONS': { 31 | 'context_processors': [ 32 | 'django.template.context_processors.request', 33 | 'django.contrib.auth.context_processors.auth', 34 | 'django.contrib.messages.context_processors.messages', 35 | ], 36 | }, 37 | }, 38 | ] 39 | ROOT_URLCONF = 'permissions_auditor.tests.fixtures.urls' 40 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | def runtests(): 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'permissions_auditor.tests.test_settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | failures = test_runner.run_tests(["permissions_auditor.tests"]) 16 | sys.exit(bool(failures)) 17 | 18 | 19 | if __name__ == "__main__": 20 | runtests() 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: 5 | README = readme.read() 6 | 7 | 8 | setup( 9 | name='django-permissions-auditor', 10 | version=__import__('permissions_auditor').__version__, 11 | description='django-permissions-auditor is a tool to audit access control on your django app.', 12 | long_description=README, 13 | long_description_content_type='text/markdown', 14 | author='AAC Engineering', 15 | url='https://github.com/AACEngineering/django-permissions-auditor', 16 | license='MIT', 17 | packages=find_packages( 18 | exclude=['example'], 19 | include=['*', 'permissions_auditor.templates.permissions_auditor.admin'] 20 | ), 21 | include_package_data=True, 22 | python_requires='>=3.5', 23 | install_requires=[ 24 | 'django>=2.1', 25 | 'setuptools', 26 | ], 27 | zip_safe=False, 28 | test_suite='runtests.runtests', 29 | classifiers=[ 30 | 'Development Status :: 5 - Production/Stable', 31 | 'Environment :: Web Environment', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Programming Language :: Python :: 3.8', 38 | 'Programming Language :: Python :: 3.9', 39 | 'Programming Language :: Python :: 3.10', 40 | 'Programming Language :: Python :: 3.11', 41 | 'Programming Language :: Python :: 3.12', 42 | 'Framework :: Django', 43 | 'Framework :: Django :: 2.2', 44 | 'Framework :: Django :: 3.2', 45 | 'Framework :: Django :: 4.1', 46 | 'Framework :: Django :: 4.2', 47 | 'Framework :: Django :: 5.0', 48 | 'Topic :: Internet :: WWW/HTTP :: Site Management', 49 | 'Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking', 50 | ], 51 | keywords='django,admin,permissions,audit,auditor', 52 | ) 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py38,py39}-django22 4 | {py38,py39,py310}-django32 5 | {py38,py39,py310,py311}-django41 6 | {py38,py39,py310,py311}-django42 7 | {py38,py39,py310,py311,py312}-django42 8 | {py310,py311,py312}-django50 9 | lint 10 | 11 | [gh-actions] 12 | python = 13 | 3.8: py38 14 | 3.9: py39 15 | 3.10: py310 16 | 3.11: py311 17 | 3.12: py312 18 | 19 | [testenv] 20 | deps = 21 | django22: Django>=2.2,<3.0 22 | django32: Django>=3.2,<4.0 23 | django40: Django>=4.0,<4.1 24 | django41: Django>=4.1,<4.2 25 | django42: Django>=4.2,<5.0 26 | django50: Django>=5.0,<5.1 27 | coverage 28 | setenv = 29 | PYTHONPATH = {toxinidir} 30 | PYTHONWARNINGS = d 31 | whitelist_externals = make 32 | pip_pre = True 33 | commands = 34 | coverage run runtests.py 35 | coverage xml 36 | 37 | 38 | [testenv:lint] 39 | basepython = python3.12 40 | commands = flake8 . 41 | deps = 42 | flake8 43 | --------------------------------------------------------------------------------