├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── TODO.txt ├── docs ├── Makefile ├── abstract.rst ├── api │ ├── core.rst │ ├── extension.rst │ ├── globals.rst │ ├── permission.rst │ ├── predicate.rst │ └── state.rst ├── conf.py ├── deploy.sh ├── index.rst └── protocol.rst ├── flask_acl ├── __init__.py ├── core.py ├── extension.py ├── globals.py ├── permission.py ├── predicate.py └── state.py ├── setup.py └── tests ├── __init__.py ├── test_core.py ├── test_extension.py ├── test_permission.py └── test_state.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /docs/_build 3 | /venv 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/_themes"] 2 | path = docs/_themes 3 | url = https://github.com/mitsuhiko/flask-sphinx-themes 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 1.0.0-dev 3 | ========= 4 | - Extension class is now ACLManager, as AuthManager assumes too much. 5 | - Many more (less public) API name changes in a quest for clarity. 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright retained by original committers. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | * Redistributions of source code must retain the above copyright 6 | notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright 8 | notice, this list of conditions and the following disclaimer in the 9 | documentation and/or other materials provided with the distribution. 10 | * Neither the name of the project nor the names of its contributors may be 11 | used to endorse or promote products derived from this software without 12 | specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, 18 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 19 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 21 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 22 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 23 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask-ACL 2 | ========= 3 | 4 | Configurable access control lists for Flask. 5 | 6 | Install via `pip install Flask-ACL` 7 | 8 | [Read the docs](http://mikeboers.github.io/Flask-ACL), and good luck! 9 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | 2 | - docs 3 | - minumum requirements: 4 | - flask.ext.login.LoginManager 5 | - a "login" entrypoint 6 | - a SECRET_KEY 7 | 8 | - tests 9 | - write some examples 10 | 11 | - AuthxManager.route_acl could be enforced via a central view checker, 12 | not by a wrapper, but this could be bypassed by other before_request handlers. 13 | The current method can already be bypassed... I guess it is up to the user to 14 | deal with those bypasses. 15 | 16 | - ACE parsing should be able to pick up arguments to predicates: 17 | "ALLOW ROLE('something') ANY" or 18 | "ALLOW ROLE.something ANY" or 19 | ('ALLOW', Role('wheel'), 'ANY') 20 | 21 | 22 | - Figure out what to do about ANY/ALL. 23 | 24 | - Should we have a mechanism for an ACL being different on a class vs its 25 | instance, or should we do that via a @classproperty? 26 | 27 | @classproperty 28 | def __acl__(cls, self): 29 | '''self may be None''' 30 | 31 | 32 | Put the predicate context onto a Flask stack proxy? 33 | 34 | from flask.ext.acl import predicate_context 35 | 36 | 37 | @auth.predicate('ROOT') 38 | define Root(user, **ctx): 39 | return 'wheel' in getattr(user, 'roles', set()) 40 | 41 | 42 | - Permission checks via functions too? 43 | 44 | @auth.permission('ANY'): 45 | define AnyPermission(permission): 46 | return True 47 | 48 | Or, treat different types differently: 49 | 50 | 51 | use for: 52 | delete -> delete, write, read 53 | write -> write, read 54 | read -> read 55 | 56 | 57 | @flask.ext.acl.register_predicate 58 | 59 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: default clean html 3 | 4 | default: html 5 | 6 | html: 7 | sphinx-build -b html . _build/html 8 | 9 | clean: 10 | rm -rf _build/* 11 | 12 | deploy: 13 | ./deploy.sh . 14 | -------------------------------------------------------------------------------- /docs/abstract.rst: -------------------------------------------------------------------------------- 1 | Overview of ACLs 2 | ================ 3 | 4 | An access control list (ACL) is a list of access control elements (ACE). An ACE is a 3-tuple of: 5 | 6 | 1. whether to allow or deny the permission 7 | 2. a predicate which determines if the ACE matches the authentication context; 8 | 3. a set of permissions that this rule applies to. 9 | 10 | To determine if the current user has a given permission on a given object in a given context, we iterate through the ACEs in the ACL of the object, testing each to see if the predicate is true in the context and the requested permission is in those that the ACE applies to. If both those conditions are met, the requested permission is either allowed or denied, as determined by the rule. 11 | 12 | For example, an English version of an ACL may be: 13 | 14 | - allow root any permission; 15 | - allow group admins to write; 16 | - allow group members to read; 17 | - deny everyone any permission. 18 | 19 | That ACL could be represented by:: 20 | 21 | [ 22 | ('ALLOW', Role('wheel'), AnyPermission), 23 | ('ALLOW', lambda user, group, **kw: user in group.members and user.is_admin, set(['write'])), 24 | ('ALLOW', lambda user, group, **kw: user in group.members, set(['read'])), 25 | ('DENY', Anyone, AnyPermission), 26 | ] 27 | 28 | 29 | Permission Sets 30 | ^^^^^^^^^^^^^^^ 31 | 32 | A "permission" is a single object that represents an action that a user may want to take. This can be a string, tuple, anyting, but usually I use strings such as ``"group.read"`` and ``"group.write"``. 33 | 34 | A "permission set" may be a single string, a collection, or a callable. 35 | 36 | If a string, a permission is in the permission set if ``permission == permission-set``. 37 | 38 | If a collection (e.g. ``set``, ``tuple``, ``list``), a permission is in the permission set if ``permission in permission_set``. 39 | 40 | If a callable, a permission is in the permission set if ``permission_set(permission)``. 41 | 42 | 43 | Predicates 44 | ^^^^^^^^^^ 45 | 46 | A "predicate" is a test against the authentication context, and returns a truth value. These are implemented as callables that take the context as keyword arguments. 47 | 48 | Several predefined predicates check for authenticated users, local users, anonymouse users, or if a user has a given principal (e.g. email or username). 49 | 50 | 51 | Access Control Elements 52 | ^^^^^^^^^^^^^^^^^^^^^^^ 53 | 54 | An ACE is a tuple of a truth value, a predicate, and a permission set. If the predicate matches, and the permission of interest is in the permission set, the truth value determines if the user is allowed to perform that action. 55 | 56 | E.g.: ``(Allow, ANY, 'read')`` will allow anyone to ``'read'`` an object. 57 | 58 | -------------------------------------------------------------------------------- /docs/api/core.rst: -------------------------------------------------------------------------------- 1 | Core API 2 | ======== 3 | 4 | .. automodule:: flask_acl.core 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/extension.rst: -------------------------------------------------------------------------------- 1 | Extension API 2 | ============= 3 | 4 | .. automodule:: flask_acl.extension 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/globals.rst: -------------------------------------------------------------------------------- 1 | Globals 2 | ======= 3 | 4 | .. automodule:: flask_acl.globals 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/permission.rst: -------------------------------------------------------------------------------- 1 | Permission API 2 | ============== 3 | 4 | .. automodule:: flask_acl.permission 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/predicate.rst: -------------------------------------------------------------------------------- 1 | Predicate API 2 | ============= 3 | 4 | .. automodule:: flask_acl.predicate 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/state.rst: -------------------------------------------------------------------------------- 1 | State API 2 | ========= 3 | 4 | .. automodule:: flask_acl.state 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Flask-ACL documentation build configuration file, created by 4 | # sphinx-quickstart on Sun May 25 11:27:51 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.todo', 34 | 'sphinx.ext.viewcode', 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'Flask-ACL' 51 | copyright = u'2014, Mike Boers' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '1.0.0' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '1.0.0' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | #language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all 77 | # documents. 78 | #default_role = None 79 | 80 | # If true, '()' will be appended to :func: etc. cross-reference text. 81 | #add_function_parentheses = True 82 | 83 | # If true, the current module name will be prepended to all description 84 | # unit titles (such as .. function::). 85 | #add_module_names = True 86 | 87 | # If true, sectionauthor and moduleauthor directives will be shown in the 88 | # output. They are ignored by default. 89 | #show_authors = False 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = 'sphinx' 93 | 94 | # A list of ignored prefixes for module index sorting. 95 | #modindex_common_prefix = [] 96 | 97 | # If true, keep warnings as "system message" paragraphs in the built documents. 98 | #keep_warnings = False 99 | 100 | 101 | # -- Options for HTML output ---------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | sys.path.append(os.path.abspath('_themes')) 106 | html_theme_path = ['_themes'] 107 | html_theme = 'flask' 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | html_theme_options = { 113 | 'index_logo': None, 114 | } 115 | 116 | # Add any paths that contain custom themes here, relative to this directory. 117 | #html_theme_path = [] 118 | 119 | # The name for this set of Sphinx documents. If None, it defaults to 120 | # " v documentation". 121 | #html_title = None 122 | 123 | # A shorter title for the navigation bar. Default is the same as html_title. 124 | #html_short_title = None 125 | 126 | # The name of an image file (relative to this directory) to place at the top 127 | # of the sidebar. 128 | #html_logo = None 129 | 130 | # The name of an image file (within the static path) to use as favicon of the 131 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 132 | # pixels large. 133 | #html_favicon = None 134 | 135 | # Add any paths that contain custom static files (such as style sheets) here, 136 | # relative to this directory. They are copied after the builtin static files, 137 | # so a file named "default.css" will overwrite the builtin "default.css". 138 | html_static_path = ['_static'] 139 | 140 | # Add any extra paths that contain custom files (such as robots.txt or 141 | # .htaccess) here, relative to this directory. These files are copied 142 | # directly to the root of the documentation. 143 | #html_extra_path = [] 144 | 145 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 146 | # using the given strftime format. 147 | #html_last_updated_fmt = '%b %d, %Y' 148 | 149 | # If true, SmartyPants will be used to convert quotes and dashes to 150 | # typographically correct entities. 151 | #html_use_smartypants = True 152 | 153 | # Custom sidebar templates, maps document names to template names. 154 | #html_sidebars = {} 155 | 156 | # Additional templates that should be rendered to pages, maps page names to 157 | # template names. 158 | #html_additional_pages = {} 159 | 160 | # If false, no module index is generated. 161 | #html_domain_indices = True 162 | 163 | # If false, no index is generated. 164 | #html_use_index = True 165 | 166 | # If true, the index is split into individual pages for each letter. 167 | #html_split_index = False 168 | 169 | # If true, links to the reST sources are added to the pages. 170 | #html_show_sourcelink = True 171 | 172 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 173 | #html_show_sphinx = True 174 | 175 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 176 | #html_show_copyright = True 177 | 178 | # If true, an OpenSearch description file will be output, and all pages will 179 | # contain a tag referring to it. The value of this option must be the 180 | # base URL from which the finished HTML is served. 181 | #html_use_opensearch = '' 182 | 183 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 184 | #html_file_suffix = None 185 | 186 | # Output file base name for HTML help builder. 187 | htmlhelp_basename = 'Flask-ACLdoc' 188 | 189 | 190 | # -- Options for LaTeX output --------------------------------------------- 191 | 192 | latex_elements = { 193 | # The paper size ('letterpaper' or 'a4paper'). 194 | #'papersize': 'letterpaper', 195 | 196 | # The font size ('10pt', '11pt' or '12pt'). 197 | #'pointsize': '10pt', 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #'preamble': '', 201 | } 202 | 203 | # Grouping the document tree into LaTeX files. List of tuples 204 | # (source start file, target name, title, 205 | # author, documentclass [howto, manual, or own class]). 206 | latex_documents = [ 207 | ('index', 'Flask-ACL.tex', u'Flask-ACL Documentation', 208 | u'Mike Boers', 'manual'), 209 | ] 210 | 211 | # The name of an image file (relative to this directory) to place at the top of 212 | # the title page. 213 | #latex_logo = None 214 | 215 | # For "manual" documents, if this is true, then toplevel headings are parts, 216 | # not chapters. 217 | #latex_use_parts = False 218 | 219 | # If true, show page references after internal links. 220 | #latex_show_pagerefs = False 221 | 222 | # If true, show URL addresses after external links. 223 | #latex_show_urls = False 224 | 225 | # Documents to append as an appendix to all manuals. 226 | #latex_appendices = [] 227 | 228 | # If false, no module index is generated. 229 | #latex_domain_indices = True 230 | 231 | 232 | # -- Options for manual page output --------------------------------------- 233 | 234 | # One entry per manual page. List of tuples 235 | # (source start file, name, description, authors, manual section). 236 | man_pages = [ 237 | ('index', 'flask-acl', u'Flask-ACL Documentation', 238 | [u'Mike Boers'], 1) 239 | ] 240 | 241 | # If true, show URL addresses after external links. 242 | #man_show_urls = False 243 | 244 | 245 | # -- Options for Texinfo output ------------------------------------------- 246 | 247 | # Grouping the document tree into Texinfo files. List of tuples 248 | # (source start file, target name, title, author, 249 | # dir menu entry, description, category) 250 | texinfo_documents = [ 251 | ('index', 'Flask-ACL', u'Flask-ACL Documentation', 252 | u'Mike Boers', 'Flask-ACL', 'One line description of project.', 253 | 'Miscellaneous'), 254 | ] 255 | 256 | # Documents to append as an appendix to all manuals. 257 | #texinfo_appendices = [] 258 | 259 | # If false, no module index is generated. 260 | #texinfo_domain_indices = True 261 | 262 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 263 | #texinfo_show_urls = 'footnote' 264 | 265 | # If true, do not generate a @detailmenu in the "Top" node's menu. 266 | #texinfo_no_detailmenu = False 267 | -------------------------------------------------------------------------------- /docs/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "${BASH_SOURCE[0]}")/_build/html" 4 | 5 | 6 | if [[ ! -d .git ]]; then 7 | git init . 8 | fi 9 | 10 | touch .nojekyll 11 | 12 | git add . 13 | git commit -m "$(date)" 14 | 15 | git push -f git@github.com:mikeboers/Flask-ACL.git HEAD:gh-pages 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Flask-ACL 2 | ========= 3 | 4 | **Flask-ACL** is a Python package which provides configurable access control lists for Flask. 5 | 6 | It is designed to allow for you to get started authorizing users immediately, but allows for a very high level of customization. 7 | 8 | 9 | Getting Started 10 | --------------- 11 | 12 | At the very minimum, you must setup a `Login Manager `_, ``SECRET_KEY``, and ``login`` view:: 13 | 14 | from flask import Flask, render_template 15 | from flask.ext.login import LoginManager 16 | from flask.ext.acl import ACLManager 17 | 18 | app = Flask(__name__) 19 | app.config['SECRET_KEY'] = 'monkey' 20 | authn = LoginManager(app) 21 | authz = ACLManager(app) 22 | 23 | @app.route('/login') 24 | def login(): 25 | return render_template('login.html'), 401 26 | 27 | 28 | Then you can start attaching ACLs to your routes: 29 | 30 | .. code-block:: python 31 | 32 | @app.route('/users_area') 33 | @authz.route_acl(''' 34 | ALLOW AUTHENTICATED http.get 35 | DENY ANY ALL 36 | ''') 37 | def users_area(): 38 | # only authenticated users will get this far 39 | 40 | 41 | You can also check for permissions on your models by defining an ``__acl__`` attribute:: 42 | 43 | class MyModel(object): 44 | 45 | __acl__ = ''' 46 | ALLOW AUTHENTICATED ALL 47 | DENY ANY ALL 48 | ''' 49 | 50 | # ... 51 | 52 | @app.route('/model/') 53 | def show_a_model(id): 54 | obj = MyModel.get(id) 55 | if not auths.can('read', obj): 56 | abort(404) 57 | else: 58 | return render_template('mymodel.html', obj=obj) 59 | 60 | 61 | Contents 62 | -------- 63 | 64 | .. toctree:: 65 | :maxdepth: 2 66 | 67 | abstract 68 | protocol 69 | 70 | 71 | API Reference 72 | ------------- 73 | 74 | .. toctree:: 75 | :maxdepth: 2 76 | 77 | api/core 78 | api/extension 79 | api/globals 80 | api/permission 81 | api/predicate 82 | api/state 83 | 84 | 85 | 86 | Indices and tables 87 | ================== 88 | 89 | * :ref:`genindex` 90 | * :ref:`modindex` 91 | * :ref:`search` 92 | 93 | -------------------------------------------------------------------------------- /docs/protocol.rst: -------------------------------------------------------------------------------- 1 | Python Protocol 2 | =============== 3 | 4 | Define ACLs on objects via an ``__acl__`` attribute. This value MUST be either a string, an interator of ACE strings, or an iterator of ACE tuples. If you provide ACE tuples permission set will not be interpreted any further, and will be used as-is. 5 | 6 | Inherit ACLs from base objects via a iterable ``__acl_bases__`` attribute, which is a sequence of other objects to look for an ``__acl__`` on. 7 | 8 | ACEs from the combined ACL will be checked for a requested permission in a given context. 9 | 10 | If you wish to build your own ACL inheritance mechanism, you MUST be sure to parse ACL strings into an ACE iterator using ``flask.ext.acl.core.iter_aces(acl)``. 11 | 12 | :: 13 | 14 | obj.__acl__ = ''' 15 | Allow ANY read 16 | Deny ANY ANY 17 | ''' 18 | check_permission('read', obj, **context) 19 | 20 | 21 | -------------------------------------------------------------------------------- /flask_acl/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_acl.core import check 2 | from flask_acl.extension import ACLManager 3 | from flask_acl.globals import current_acl_manager 4 | -------------------------------------------------------------------------------- /flask_acl/core.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from flask_acl.permission import parse_permission_set, is_permission_in_set 4 | from flask_acl.predicate import parse_predicate 5 | from flask_acl.state import parse_state 6 | 7 | 8 | def parse_acl(acl_iter): 9 | """Parse a string, or list of ACE definitions, into usable ACEs.""" 10 | 11 | if isinstance(acl_iter, basestring): 12 | acl_iter = [acl_iter] 13 | 14 | for chunk in acl_iter: 15 | 16 | if isinstance(chunk, basestring): 17 | chunk = chunk.splitlines() 18 | chunk = [re.sub(r'#.+', '', line).strip() for line in chunk] 19 | chunk = filter(None, chunk) 20 | else: 21 | chunk = [chunk] 22 | 23 | for ace in chunk: 24 | 25 | # If this was provided as a string, then parse the permission set. 26 | # Otherwise, use it as-is, which will result in an equality test. 27 | if isinstance(ace, basestring): 28 | ace = ace.split(None, 2) 29 | state, predicate, permission_set = ace 30 | yield parse_state(state), parse_predicate(predicate), parse_permission_set(permission_set) 31 | else: 32 | state, predicate, permission_set = ace 33 | yield parse_state(state), parse_predicate(predicate), permission_set 34 | 35 | 36 | 37 | def iter_object_graph(obj, parents_first=False): 38 | 39 | if not parents_first: 40 | yield obj 41 | for base in getattr(obj, '__acl_bases__', ()): 42 | for x in iter_object_graph(base, parents_first): 43 | yield x 44 | if parents_first: 45 | yield obj 46 | 47 | 48 | def iter_object_acl(root): 49 | """Child-first discovery of ACEs for an object. 50 | 51 | Walks the ACL graph via ``__acl_bases__`` and yields the ACEs parsed from 52 | ``__acl__`` on each object. 53 | 54 | """ 55 | 56 | for obj in iter_object_graph(root): 57 | for ace in parse_acl(getattr(obj, '__acl__', ())): 58 | yield ace 59 | 60 | 61 | def get_object_context(root): 62 | """Depth-first discovery of authentication context for an object. 63 | 64 | Walks the ACL graph via ``__acl_bases__`` and merges the ``__acl_context__`` 65 | attributes. 66 | 67 | """ 68 | 69 | context = {} 70 | for obj in iter_object_graph(root, parents_first=True): 71 | context.update(getattr(obj, '__acl_context__', {})) 72 | return context 73 | 74 | 75 | 76 | 77 | 78 | def check(permission, raw_acl, **context): 79 | # log.debug('check for %r in %s' % (permission, pformat(context))) 80 | for state, predicate, permission_set in parse_acl(raw_acl): 81 | pred_match = predicate(**context) 82 | perm_match = is_permission_in_set(permission, permission_set) 83 | # log.debug('can %s %r(%s) %r%s' % ( 84 | # 'ALLOW' if state else 'DENY', 85 | # predicate, pred_match, 86 | # permission_set, 87 | # ' -> ' + ('ALLOW' if state else 'DENY') + ' ' + permission if (pred_match and perm_match) else '', 88 | # )) 89 | if pred_match and perm_match: 90 | return state 91 | 92 | -------------------------------------------------------------------------------- /flask_acl/extension.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import functools 4 | import logging 5 | from pprint import pformat 6 | from urllib.parse import urlencode 7 | 8 | import flask 9 | from flask import request, current_app 10 | from flask_login import current_user 11 | import werkzeug as wz 12 | 13 | from flask_acl.core import iter_object_acl, get_object_context, check 14 | from flask_acl.permission import default_permission_sets 15 | from flask_acl.predicate import default_predicates 16 | 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | 21 | class _Redirect(Exception): 22 | pass 23 | 24 | 25 | class ACLManager(object): 26 | 27 | """Flask extension for registration and checking of ACLs on routes and other objects.""" 28 | 29 | login_view = 'login' 30 | 31 | def __init__(self, app=None): 32 | self.context_processors = [] 33 | self.predicates = default_predicates.copy() 34 | self.predicate_parsers = [] 35 | self.permission_sets = default_permission_sets.copy() 36 | self.permission_set_parsers = [] 37 | if app: 38 | self.init_app(app) 39 | 40 | def init_app(self, app): 41 | 42 | app.acl_manager = self 43 | app.extensions['acl'] = self 44 | 45 | app.config.setdefault('ACL_ROUTE_DEFAULT_STATE', True) 46 | 47 | # I suspect that Werkzeug has something for this already... 48 | app.errorhandler(_Redirect)(lambda r: flask.redirect(r.args[0])) 49 | 50 | def context_processor(self, func): 51 | """Register a function to build authorization contexts. 52 | 53 | The function is called with no arguments, and must return a dict of new 54 | context material. 55 | 56 | """ 57 | self.context_processors.append(func) 58 | 59 | def predicate_parser(self, func): 60 | """Define a new predicate parser. 61 | 62 | E.g.:: 63 | 64 | @authz.predicate_parser 65 | def parse_groups(pred): 66 | if pred.startswith('group:'): 67 | return Group(pred.split(':')[1]) 68 | 69 | """ 70 | self.predicate_parsers.append(func) 71 | 72 | def permission_set_parser(self, func): 73 | """Define a new permission set parser. 74 | 75 | E.g.:: 76 | 77 | @authz.permission_set_parser 78 | def parse_globs(pattern): 79 | if '*' in pattern: 80 | reobj = re.compile(fnmatch.translate(pattern)) 81 | return reobj.match 82 | 83 | """ 84 | self.permission_set_parser.append(func) 85 | 86 | def predicate(self, name, func=None): 87 | """Define a new predicate (directly, or as a decorator). 88 | 89 | E.g.:: 90 | 91 | @authz.predicate('ROOT') 92 | def is_root(user, **ctx): 93 | # return True of user is in group "wheel". 94 | 95 | """ 96 | if func is None: 97 | return functools.partial(self.predicate, name) 98 | self.predicates[name] = func 99 | return func 100 | 101 | def permission_set(self, name, func=None): 102 | """Define a new permission set (directly, or as a decorator). 103 | 104 | E.g.:: 105 | 106 | @authz.permission_set('HTTP') 107 | def is_http_perm(perm): 108 | return perm.startswith('http.') 109 | 110 | """ 111 | if func is None: 112 | return functools.partial(self.predicate, name) 113 | self.permission_sets[name] = func 114 | return func 115 | 116 | def route_acl(self, *acl, **options): 117 | """Decorator to attach an ACL to a route. 118 | 119 | E.g:: 120 | 121 | @app.route('/url/to/view') 122 | @authz.route_acl(''' 123 | ALLOW WHEEL ALL 124 | DENY ANY ALL 125 | ''') 126 | def my_admin_function(): 127 | pass 128 | 129 | """ 130 | 131 | def _route_acl(func): 132 | 133 | func.__acl__ = acl 134 | 135 | @functools.wraps(func) 136 | def wrapped(*args, **kwargs): 137 | permission = 'http.' + request.method.lower() 138 | local_opts = options.copy() 139 | local_opts.setdefault('default', current_app.config['ACL_ROUTE_DEFAULT_STATE']) 140 | self.assert_can(permission, func, **local_opts) 141 | return func(*args, **kwargs) 142 | 143 | return wrapped 144 | return _route_acl 145 | 146 | def can(self, permission, obj, **kwargs): 147 | """Check if we can do something with an object. 148 | 149 | :param permission: The permission to look for. 150 | :param obj: The object to check the ACL of. 151 | :param **kwargs: The context to pass to predicates. 152 | 153 | >>> auth.can('read', some_object) 154 | >>> auth.can('write', another_object, group=some_group) 155 | 156 | """ 157 | 158 | context = {'user': current_user} 159 | for func in self.context_processors: 160 | context.update(func()) 161 | context.update(get_object_context(obj)) 162 | context.update(kwargs) 163 | return check(permission, iter_object_acl(obj), **context) 164 | 165 | def assert_can(self, permission, obj, **kwargs): 166 | """Make sure we have a permission, or abort the request. 167 | 168 | :param permission: The permission to look for. 169 | :param obj: The object to check the ACL of. 170 | :param flash: The message to flask if denied (keyword only). 171 | :param stealth: Abort with a 404? (keyword only). 172 | :param **kwargs: The context to pass to predicates. 173 | 174 | """ 175 | flash_message = kwargs.pop('flash', None) 176 | stealth = kwargs.pop('stealth', False) 177 | default = kwargs.pop('default', None) 178 | 179 | res = self.can(permission, obj, **kwargs) 180 | res = default if res is None else res 181 | 182 | if not res: 183 | if flash_message and not stealth: 184 | flask.flash(flash_message, 'danger') 185 | if current_user.is_authenticated(): 186 | if flash_message is not False: 187 | flask.flash(flash_message or 'You are not permitted to "%s" this resource' % permission) 188 | flask.abort(403) 189 | elif not stealth and self.login_view: 190 | if flash_message is not False: 191 | flask.flash(flash_message or 'Please login for access.') 192 | raise _Redirect(flask.url_for(self.login_view) + '?' + urlencode(dict(next= 193 | flask.request.script_root + flask.request.path 194 | ))) 195 | else: 196 | flask.abort(404) 197 | 198 | def can_route(self, endpoint, method=None, **kwargs): 199 | """Make sure we can route to the given endpoint or url. 200 | 201 | This checks for `http.get` permission (or other methods) on the ACL of 202 | route functions, attached via the `ACL` decorator. 203 | 204 | :param endpoint: A URL or endpoint to check for permission to access. 205 | :param method: The HTTP method to check; defaults to `'GET'`. 206 | :param **kwargs: The context to pass to predicates. 207 | 208 | """ 209 | 210 | view = flask.current_app.view_functions.get(endpoint) 211 | if not view: 212 | endpoint, args = flask._request_ctx.top.match(endpoint) 213 | view = flask.current_app.view_functions.get(endpoint) 214 | if not view: 215 | return False 216 | 217 | return self.can('http.' + (method or 'GET').lower(), view, **kwargs) 218 | 219 | -------------------------------------------------------------------------------- /flask_acl/globals.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import werkzeug as wz 4 | from flask import current_app 5 | 6 | #: Proxy to the current Flask app's :class:`.ACLManager`. 7 | current_acl_manager = wz.local.LocalProxy(lambda: current_app.acl_manager) 8 | -------------------------------------------------------------------------------- /flask_acl/permission.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Container, Callable 2 | 3 | from flask_acl.globals import current_acl_manager 4 | 5 | 6 | # Permissions 7 | class All(object): 8 | def __contains__(self, other): 9 | return True 10 | def __repr__(self): 11 | return 'ALL' 12 | 13 | 14 | default_permission_sets = { 15 | 'ALL': All(), 16 | 'ANY': All(), # Common synonym. 17 | 'http.get': set(('http.get', 'http.head', 'http.options')), 18 | } 19 | 20 | 21 | def parse_permission_set(input): 22 | """Lookup a permission set name in the defined permissions. 23 | 24 | Requires a Flask app context. 25 | 26 | """ 27 | 28 | # Priority goes to the user's parsers. 29 | if isinstance(input, basestring): 30 | for func in current_acl_manager.permission_set_parsers: 31 | res = func(input) 32 | if res is not None: 33 | input = res 34 | break 35 | 36 | if isinstance(input, basestring): 37 | try: 38 | return current_acl_manager.permission_sets[input] 39 | except KeyError: 40 | raise ValueError('unknown permission set %r' % input) 41 | 42 | return input 43 | 44 | 45 | def is_permission_in_set(perm, perm_set): 46 | """Test if a permission is in the given set. 47 | 48 | :param perm: The permission object to check for. 49 | :param perm_set: The set to check in. If a ``str``, the permission is 50 | checked for equality. If a container, the permission is looked for in 51 | the set. If a function, the permission is passed to the "set". 52 | 53 | """ 54 | 55 | if isinstance(perm_set, basestring): 56 | return perm == perm_set 57 | elif isinstance(perm_set, Container): 58 | return perm in perm_set 59 | elif isinstance(perm_set, Callable): 60 | return perm_set(perm) 61 | else: 62 | raise TypeError('permission set must be a string, container, or callable') 63 | -------------------------------------------------------------------------------- /flask_acl/predicate.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | 3 | from flask_acl.globals import current_acl_manager 4 | 5 | 6 | def parse_predicate(input): 7 | 8 | # Priority goes to the user's parsers. 9 | if isinstance(input, basestring): 10 | for func in current_acl_manager.predicate_parsers: 11 | res = func(input) 12 | if res is not None: 13 | input = res 14 | break 15 | 16 | if isinstance(input, basestring): 17 | negate = input.startswith('!') 18 | if negate: 19 | input = input[1:] 20 | predicate = current_acl_manager.predicates.get(input) 21 | if not predicate: 22 | raise ValueError('unknown predicate: %r' % input) 23 | if negate: 24 | predicate = Not(predicate) 25 | return predicate 26 | 27 | if isinstance(input, (tuple, list)): 28 | return And(parse_predicate(x) for x in input) 29 | 30 | return input 31 | 32 | 33 | class Any(object): 34 | def __call__(self, **kw): 35 | return True 36 | def __repr__(self): 37 | return 'ANY' 38 | 39 | 40 | class Not(object): 41 | def __init__(self, predicate): 42 | self.predicate = predicate 43 | def __call__(self, **kw): 44 | return not self.predicate(**kw) 45 | def __repr__(self): 46 | return 'NOT(%r)' % self.predicate 47 | 48 | 49 | class And(object): 50 | 51 | op = all 52 | 53 | def __init__(self, *predicates): 54 | self.predicates = predicates 55 | def __call__(self, **kw): 56 | return self.op(x(**kw) for x in self.predicates) 57 | def __repr__(self): 58 | return '%s(%s)' % (self.op.__name__.upper(), ', '.join(repr(x) for x in self.predicates)) 59 | 60 | 61 | class Or(And): 62 | op = any 63 | 64 | 65 | class Authenticated(object): 66 | def __call__(self, user, **kw): 67 | return user.is_authenticated 68 | def __repr__(self): 69 | return 'AUTHENTICATED' 70 | 71 | 72 | class Active(object): 73 | def __call__(self, user, **kw): 74 | return user.is_active 75 | def __repr__(self): 76 | return 'ACTIVE' 77 | 78 | 79 | class Anonymous(object): 80 | def __call__(self, user, **kw): 81 | return user.is_anonymous 82 | def __repr__(self): 83 | return 'ANONYMOUS' 84 | 85 | 86 | class Local(object): 87 | def __call__(self, **kw): 88 | return request.remote_addr in ('127.0.0.1', '::0', '::1') 89 | def __repr__(self): 90 | return 'LOCAL' 91 | 92 | 93 | Remote = lambda: Not(Local()) 94 | 95 | 96 | default_predicates = { 97 | 'ACTIVE': Active(), 98 | 'ANONYMOUS': Anonymous(), 99 | 'AUTHENTICATED': Authenticated(), 100 | 'LOCAL': Local(), 101 | 'REMOTE': Remote(), 102 | 'ANY': Any(), 103 | 'ALL': Any(), # Common synonym. 104 | } 105 | 106 | -------------------------------------------------------------------------------- /flask_acl/state.py: -------------------------------------------------------------------------------- 1 | _state_strings = dict( 2 | allow=True, deny=False, 3 | grant=True, reject=False, 4 | ) 5 | 6 | 7 | def parse_state(state): 8 | """Convert a bool, or string, into a bool. 9 | 10 | The string pairs we respond to (case insensitively) are: 11 | 12 | - ALLOW & DENY 13 | - GRANT & REJECT 14 | 15 | :returns bool: ``True`` or ``False``. 16 | :raises ValueError: when not a ``bool`` or one of the above strings. 17 | 18 | E.g.:: 19 | 20 | >>> parse_state('Allow') 21 | True 22 | 23 | """ 24 | if isinstance(state, bool): 25 | return state 26 | if not isinstance(state, basestring): 27 | raise TypeError('ACL state must be bool or string') 28 | try: 29 | return _state_strings[state.lower()] 30 | except KeyError: 31 | raise ValueError('unknown ACL state string') 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='Flask-ACL', 5 | version='0.0.1', 6 | description='Access control lists for Flask.', 7 | url='http://github.com/mikeboers/Flask-ACL', 8 | 9 | author='Mike Boers', 10 | author_email='flask-acl@mikeboers.com', 11 | license='BSD-3', 12 | 13 | install_requires=[ 14 | 'Flask', 15 | 'Flask-Login', 16 | ], 17 | 18 | classifiers=[ 19 | 'Development Status :: 5 - Production/Stable', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Natural Language :: English', 23 | 'Operating System :: OS Independent', 24 | 'Programming Language :: Python :: 2', 25 | ], 26 | packages=['flask_acl'], 27 | 28 | ) 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from flask.ext.acl import ACLManager 4 | from flask.ext.login import LoginManager 5 | from flask import Flask 6 | 7 | 8 | class FlaskTestCase(TestCase): 9 | 10 | def setUp(self): 11 | 12 | self.flask = Flask('tests') 13 | self.flask.config['SECRET_KEY'] = 'deadbeef' 14 | self.authn = LoginManager(self.flask) 15 | self.authz = ACLManager(self.flask) 16 | self.client = self.flask.test_client() 17 | 18 | @self.flask.route('/login') 19 | @self.authz.route_acl('ALLOW ANY ALL') 20 | def login(): 21 | return 'please login', 401 22 | 23 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | from flask_acl.core import check 4 | 5 | 6 | class TestCoreCheck(FlaskTestCase): 7 | 8 | def test_empty(self): 9 | self.assertIs(None, check('permission', [])) 10 | 11 | def test_always_allow(self): 12 | with self.client: 13 | self.client.get('/') 14 | self.assertIs(True, check('permission', ''' 15 | ALLOW ANY ALL 16 | ''')) 17 | 18 | def test_always_deny(self): 19 | with self.client: 20 | self.client.get('/') 21 | self.assertIs(False, check('permission', ''' 22 | DENY ANY ALL 23 | ''')) 24 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | from flask_acl.globals import current_acl_manager 4 | 5 | 6 | class TestExtension(FlaskTestCase): 7 | 8 | def test_app_registry(self): 9 | self.assertIs(self.flask.acl_manager, self.authz) 10 | 11 | def test_current_acl_manager(self): 12 | with self.flask.test_request_context('/'): 13 | self.assertIs(current_acl_manager._get_current_object(), self.authz) 14 | 15 | def test_route_default(self): 16 | @self.flask.route('/default_allow') 17 | @self.authz.route_acl('') 18 | def default_allow(): 19 | return 'allowed' 20 | with self.client: 21 | rv = self.client.get('/default_allow') 22 | self.assertEqual(rv.status_code, 200) 23 | self.assertEqual(rv.data, 'allowed') 24 | 25 | def test_route_default_deny(self): 26 | self.flask.config['ACL_ROUTE_DEFAULT_STATE'] = False 27 | @self.flask.route('/default_deny') 28 | @self.authz.route_acl('') 29 | def default_deny(): 30 | return 'allowed' 31 | with self.client: 32 | rv = self.client.get('/default_deny', follow_redirects=True) 33 | self.assertEqual(rv.status_code, 401) 34 | self.assertEqual(rv.data, 'please login') 35 | 36 | def test_route_allow(self): 37 | 38 | @self.flask.route('/allow') 39 | @self.authz.route_acl(''' 40 | ALLOW ANY ALL 41 | ''') 42 | def allow(): 43 | return 'allowed' 44 | 45 | with self.client: 46 | rv = self.client.get('/allow') 47 | self.assertEqual(rv.status_code, 200) 48 | self.assertEqual(rv.data, 'allowed') 49 | 50 | def test_route_deny(self): 51 | 52 | @self.flask.route('/deny') 53 | @self.authz.route_acl(''' 54 | DENY ANY ALL 55 | ''') 56 | def deny(): 57 | return 'allowed' 58 | 59 | with self.client: 60 | rv = self.client.get('/deny', follow_redirects=True) 61 | self.assertEqual(rv.status_code, 401) 62 | self.assertEqual(rv.data, 'please login') 63 | 64 | def test_route_deny_stealth(self): 65 | 66 | @self.flask.route('/stealth') 67 | @self.authz.route_acl(''' 68 | DENY ANY ALL 69 | ''', stealth=True) 70 | def stealth(): 71 | return 'allowed' 72 | 73 | with self.client: 74 | rv = self.client.get('/stealth') 75 | self.assertEqual(rv.status_code, 404) 76 | 77 | 78 | -------------------------------------------------------------------------------- /tests/test_permission.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | from flask_acl.permission import is_permission_in_set 4 | 5 | 6 | class TestPermissions(TestCase): 7 | 8 | def test_strings(self): 9 | self.assertTrue(is_permission_in_set('xxx', 'xxx')) 10 | self.assertFalse(is_permission_in_set('xxx', 'axxx')) 11 | self.assertFalse(is_permission_in_set('xxx', 'xxxb')) 12 | 13 | def test_containers(self): 14 | self.assertTrue(is_permission_in_set('xxx', ('a', 'xxx', 'b'))) 15 | self.assertTrue(is_permission_in_set('xxx', ['a', 'xxx', 'b'])) 16 | self.assertTrue(is_permission_in_set('xxx', set(['a', 'xxx', 'b']))) 17 | self.assertFalse(is_permission_in_set('xxx', ('a', 'b'))) 18 | self.assertFalse(is_permission_in_set('xxx', ['a', 'b'])) 19 | self.assertFalse(is_permission_in_set('xxx', set(['a', 'b']))) 20 | 21 | def test_callables(self): 22 | self.assertTrue(is_permission_in_set('xxx', lambda p: True)) 23 | self.assertTrue(is_permission_in_set('xxx', lambda p: 'x' in p)) 24 | self.assertFalse(is_permission_in_set('xxx', lambda p: 'X' in p)) 25 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | from flask_acl.state import parse_state 4 | 5 | 6 | class TestState(TestCase): 7 | 8 | def test_parsing_strings(self): 9 | for state, res in [ 10 | ('Allow', True), 11 | ('Deny', False), 12 | ('Grant', True), 13 | ('Reject', False), 14 | ]: 15 | self.assertIs(res, parse_state(state)) 16 | self.assertIs(res, parse_state(state.upper())) 17 | self.assertIs(res, parse_state(state.lower())) 18 | 19 | def test_parsing_invalid_state(self): 20 | self.assertRaises(TypeError, parse_state, 1) 21 | self.assertRaises(TypeError, parse_state, {}) 22 | self.assertRaises(ValueError, parse_state, 'not a state') 23 | 24 | --------------------------------------------------------------------------------