├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── CHANGES ├── LICENSE ├── Makefile ├── README.md ├── fastapi_permissions ├── __init__.py └── example.py ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── test_all_constant.py ├── test_example_app.py ├── test_example_openapi_specs.py ├── test_permissions.py └── test_utility_functions.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Mac-Stuff 107 | .DS_Store 108 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.4.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-byte-order-marker 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-toml 10 | - id: debug-statements 11 | - id: detect-private-key 12 | - repo: local 13 | hooks: 14 | - id: isort-project 15 | name: isort_project 16 | entry: isort -rc fastapi_permissions 17 | language: system 18 | pass_filenames: false 19 | - id: isort-test 20 | name: isort_test 21 | entry: isort -rc tests 22 | language: system 23 | pass_filenames: false 24 | - id: black 25 | name: black 26 | entry: black fastapi_permissions tests 27 | language: system 28 | pass_filenames: false 29 | - id: flake8 30 | name: flake8 31 | entry: flake8 fastapi_permissions tests 32 | language: system 33 | pass_filenames: false 34 | - id: pytest 35 | name: pytest 36 | entry: pytest tests 37 | pass_filenames: false 38 | language: system 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.7" 5 | 6 | # command to install dependencies 7 | install: 8 | - pip install --upgrade pip 9 | - pip install --upgrade pytest 10 | - pip install flit 11 | - flit install --pth-file 12 | 13 | # command to run tests 14 | script: 15 | - tox 16 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.2.7 - Heartbeat, Oct. 2020 2 | ---------------------------- 3 | 4 | - A quick reminder that this is not dead yet. 5 | - Added "python-multipart" as a requirement 6 | 7 | 8 | 0.2.6 - OpenAPI Tests, Aug. 2020 9 | -------------------------------- 10 | 11 | - Changed the list based ACLs in the example app to catch errors in the test 12 | - Added tests for the OpenAPI specification of the example app 13 | This is a reaction to the "List Fix" problems I had and that were finally 14 | resolved by William. 15 | 16 | 17 | 0.2.5 - List Fix II, Jul. 2020 18 | ------------------------------ 19 | 20 | - While providing a fix for ACLs as a list, another serious error was 21 | introduced. Luckily William tried this out and provided a (hopefully) 22 | final fix. 23 | 24 | 25 | 0.2.4 - List Fix, Jul. 2020 26 | --------------------------- 27 | 28 | - When providing ACLs as a list, the permissions are now checked correctly 29 | (Thanks to William for pointing out this issue) 30 | 31 | 32 | 0.2.2 - Heartbeat, Feb. 2020 33 | ---------------------------- 34 | 35 | - A quick reminder that this is not dead yet. 36 | - Added pre-commit hook and isort for imports 37 | - Some adjustments in the Makefile. 38 | 39 | 40 | 0.2.1 - Depends 41 | --------------- 42 | 43 | - wrapping the result of permission_dependency_factory in Depends(), 44 | simplifying the use in path operation functions 45 | 46 | 47 | 0.2.0 - unpyramidify 48 | -------------------- 49 | 50 | - Removing some ideas borrowed from pyramid and some personal preference. 51 | 52 | 53 | 0.1.0 - initial release 54 | ------------------------ 55 | 56 | - First working implementation 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * ---------------------------------------------------------------------------- 3 | * "THE BEER-WARE LICENSE" (Revision 42): 4 | * wrote this file. As long as you retain this notice you 5 | * can do whatever you want with this stuff. If we meet some day, and you think 6 | * this stuff is worth it, you can buy me a beer in return. Holger Frey 7 | * ---------------------------------------------------------------------------- 8 | */ 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .pytest_cache/ 49 | rm -fr .tox/ 50 | rm -f .coverage 51 | rm -fr htmlcov/ 52 | 53 | lint: ## reformat with black and check style with flake8 54 | isort fastapi_permissions 55 | isort tests 56 | black fastapi_permissions tests 57 | flake8 fastapi_permissions tests 58 | 59 | test: ## run tests quickly with the default Python 60 | pytest tests -x --disable-warnings -k "not app" 61 | 62 | coverage: ## full test suite, check code coverage and open coverage report 63 | pytest tests --cov=fastapi_permissions 64 | coverage html 65 | $(BROWSER) htmlcov/index.html 66 | 67 | tox: ## run fully isolated tests with tox 68 | tox 69 | 70 | install: ## install updated project.toml with flint 71 | flit install --pth-file 72 | 73 | devenv: ## setup development environment 74 | python3 -m venv --prompt permissions .venv 75 | .venv/bin/pip3 install --upgrade pip 76 | .venv/bin/pip3 install flit 77 | .venv/bin/flit install --pth-file 78 | 79 | repo: devenv ## complete project setup with development environment and git repo 80 | git init . 81 | .venv/bin/pre-commit install 82 | git add . 83 | git commit -m "import of project template" --no-verify 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Row Level Permissions for FastAPI 2 | ================================= 3 | 4 | [![Build Status](https://travis-ci.com/holgi/fastapi-permissions.svg?branch=master)](https://travis-ci.com/holgi/fastapi-permissions) 5 | 6 | While trying out the excellent [FastApi][] framework there was one peace missing for me: an easy, declarative way to define permissions of users (and roles/groups) on resources. Since I reall love the way [Pyramid][] handles this, I re-implemented and adapted the system for FastApi (well, you might call it a blatant rip-off). 7 | 8 | 9 | An extremely simple and incomplete example: 10 | ------------------------------------------- 11 | 12 | ```python 13 | from fastapi import Depends, FastAPI 14 | from fastapi.security import OAuth2PasswordBearer 15 | from fastapi_permissions import configure_permissions, Allow, Deny 16 | from pydantic import BaseModel 17 | 18 | app = FastAPI() 19 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") 20 | 21 | class Item(BaseModel): 22 | name: str 23 | owner: str 24 | 25 | def __acl__(self): 26 | return [ 27 | (Allow, Authenticated, "view"), 28 | (Allow, "role:admin", "edit"), 29 | (Allow, f"user:{self.owner}", "delete"), 30 | ] 31 | 32 | class User(BaseModel): 33 | name: str 34 | 35 | def principals(self): 36 | return [f"user:{self.name}"] 37 | 38 | def get_current_user(token: str = Depends(oauth2_scheme)): 39 | ... 40 | 41 | def get_active_user_principals(user:User = Depends(get_current_user)): 42 | ... 43 | 44 | def get_item(item_identifier): 45 | ... 46 | 47 | # Permission is already wrapped in Depends() 48 | Permission = configure_permissions(get_active_user_principals) 49 | 50 | @app.get("/item/{item_identifier}") 51 | async def show_item(item: Item=Permission("view", get_item)): 52 | return [{"item": item}] 53 | ``` 54 | 55 | For a better example install ```fastapi_permissions``` source in an virtual environment (see further below), and start a test server: 56 | 57 | ``` 58 | (permissions) $ uvicorn fastapi_permissions.example:app --reload 59 | ``` 60 | 61 | Visit to try it out. There are two users available: "bob" and "alice", both have the password "secret". 62 | 63 | The example is derived from the FastApi examples, so it should be familiar. New / added stuff is marked with comments in the source file `fastapi_permissions/example.py` 64 | 65 | 66 | Why not use Scopes? 67 | ------------------- 68 | 69 | For most applications the use of [scopes][] to determine the rights of a user is sufficient enough. So if scopes fit your application, please use them - they are already a part of the FastAPI framework. 70 | 71 | While scopes are tied only to the state of the user, `fastapi_permissions` also 72 | take the state of the requested resource into account. 73 | 74 | Let's take an scientific paper as an example: depending on the state of the submission process (like "draft", "submitted", "peer review" or "published") different users should have different permissions on viewing, editing or retracting. This could be acomplished with custom code in the path definition functions, but `fastapi_permissions` offers a method to define these constraints in a single place. 75 | 76 | There is a second case, where `fastapi_permissions` might be the right addition to your app: If your brain is wired / preconditioned like mine to such a permission model - e.g. exposed for a long time to [Pyramid][]... 77 | 78 | Long Story Short: Use [scopes][] until you need something different. 79 | 80 | 81 | Concepts 82 | -------- 83 | 84 | Since `fastapi_permissions` heavely derived from the [Pyramid][] framework, I strongly suggest to take a look at its [security documentation][pyramid_security] if anything is unclear to you. 85 | 86 | The system depends on a couple of concepts not found in FastAPI: 87 | 88 | - **resources**: objects that provide an *access controll list* 89 | - **access controll lists**: a list of rules defining which *principal* has what *permission* 90 | - **principal**: an identifier of a user or his/her associated groups/roles 91 | - **permission**: an identifier (string) for an action on an object 92 | 93 | ### resources & access controll lists 94 | 95 | A resource provides an access controll list via it's ```__acl__``` attribute. It can either be an property of an object or a callable. Each entry in the list is a tuple containing three values: 96 | 97 | 1. an action: ```fastapi_permissions.Allow``` or ```fastapi_permissions.Deny``` 98 | 2. a principal: e.g. "role:admin" or "user:bob" 99 | 3. a permission or a tuple thereof: e.g. "edit" or ("view", "delete") 100 | 101 | Examples: 102 | 103 | ```python 104 | from fastapi_permissions import Allow, Deny, Authenticated, Everyone 105 | 106 | class StaticAclResource: 107 | __acl__ = [ 108 | (Allow, Everyone, "view"), 109 | (Allow, "role:user", "share") 110 | ] 111 | 112 | class DynamicAclResource: 113 | def __acl__(self): 114 | return [ 115 | (Allow, Authenticated, "view"), 116 | (Allow, "role:user", "share"), 117 | (Allow, f"user:{self.owner}", "edit"), 118 | ] 119 | 120 | # in contrast to pyramid, resources might be access conroll list themselves 121 | # this can save some typing: 122 | 123 | AclResourceAsList = [(Allow, Everyone, "view"), (Deny, "role:troll", "edit")] 124 | ``` 125 | 126 | You don't need to add any "deny-all-clause" at the end of the access controll list, this is automagically implied. All entries in a ACL are checked in *the order provided in the list*. This makes some complex configurations simple, but can sometimes be a pain in the lower back… 127 | 128 | The two principals ```Everyone``` and ```Authenticated``` will be discussed in short time. 129 | 130 | ### users & principal identifiers 131 | 132 | You **must provide** a function that returns the principals of the current active user. The principals is just a list of strings, identifying the user and groups/roles the user belongs to: 133 | 134 | Example: 135 | 136 | ```python 137 | def get_active_principals(user: User = Depends(get_current_user)): 138 | if user: 139 | # user is logged in 140 | principals = [Everyone, Authenticated] 141 | principals.extend(getattr(user, "principals", [])) 142 | else: 143 | # user is not logged in 144 | principals = [Everyone] 145 | return principals 146 | ``` 147 | 148 | #### special principals 149 | 150 | There are two special principals that also help providing access controll lists: ```Everyone``` and ```Authenticated```. 151 | 152 | The ```Everyone``` principal should be added regardless of any other defined principals or login status, ```Authenticated``` should only be added for a user that is logged in. 153 | 154 | ### permissions 155 | 156 | A permission is just a string that represents an action to be performed on a resource. Just make something up. 157 | 158 | As with the special principals, there is a special permission that is usable as a wildcard: ```fastapi_permisssions.All```. 159 | 160 | 161 | Usage 162 | ----- 163 | 164 | There are some things you must provide before using the permissions system: 165 | 166 | - a callable ([FastApi dependency][dependency]) that returns the principal of the logged in (active) user 167 | - a resource with an access controll list 168 | 169 | ### Configuring the permissions system 170 | 171 | Simple configuration with some defaults: 172 | 173 | ```python 174 | from fastapi_permissions import configure_permissions 175 | 176 | # must be provided 177 | def get_active_principals(...): 178 | """ returns the principals of the current logged in user""" 179 | ... 180 | 181 | # Permission is already wrapped in Depends() 182 | Permission = configure_permissions(get_active_principals) 183 | ``` 184 | 185 | One configuration option is available: 186 | 187 | - permission_exception: 188 | - this exception will be raised if a permission is denied 189 | - defaults to fastapi_permissions.permission_exception 190 | 191 | ```python 192 | from fastapi_permissions import configure_permissions 193 | 194 | # must be provided 195 | def get_active_principals(...): 196 | """ returns the principals of the current logged in user""" 197 | ... 198 | 199 | # Permission is already wrapped in Depends() 200 | Permission = configure_permissions( 201 | get_active_principals, 202 | permission_exception 203 | 204 | ) 205 | ``` 206 | 207 | ### using permissions in path operation 208 | 209 | To use access controll in a path operation, you call the perviously configured function with a permission and the resource. If the permission is granted, the requested resource the permission is checked on will be returned, or in this case, the acl list 210 | 211 | ```python 212 | from fastapi_permissions import configure_permissions, Allow 213 | 214 | # must be provided 215 | def get_active_principals(...): 216 | """ returns the principals of the current logged in user""" 217 | ... 218 | 219 | example_acl = [(Allow, "role:user", "view")] 220 | 221 | # Permission is already wrapped in Depends() 222 | Permission = configure_permissions(get_active_principals) 223 | 224 | @app.get("/") 225 | async def root(acls:list=Permission("view", example_acl)): 226 | return {"OK"} 227 | ``` 228 | 229 | Instead of using an access controll list directly, you can also provide a dependency function that might fetch a resource from a database, the resouce should provide its access controll list via the `__acl__` attribute: 230 | 231 | ```python 232 | from fastapi_permissions import configure_permissions, Allow 233 | 234 | # must be provided 235 | def get_active_principals(...): 236 | """ returns the principals of the current logged in user""" 237 | ... 238 | 239 | # fetches a resource from the database 240 | def get_item(item_id: int): 241 | """ returns a resource from the database 242 | 243 | The resource provides an access controll list via its "__acl__" attribute. 244 | """ 245 | ... 246 | 247 | # Permission is alredy wrapped in Depends() 248 | Permission = configure_permissions(get_active_principals) 249 | 250 | @app.get("/item/{item_id}") 251 | async def show_item(item:Item=Permission("view", get_item)): 252 | return {"item": item} 253 | ``` 254 | 255 | ### helper functions 256 | 257 | Sometimes you might want to check permissions inside a function and not as the definition of a path operation: 258 | 259 | With ```has_permission(user_principals, permission, resource)``` you can preform the permission check programatically. The function signature can easily be remebered with something like "John eat apple?". The result will be either ```True``` or ```False```, so no need for try/except blocks \o/. 260 | 261 | ```python 262 | from fastapi_permissions import ( 263 | has_permission, Allow, All, Everyone, Authenticated 264 | ) 265 | 266 | user_principals == [Everyone, Authenticated, "role:owner", "user:bob"] 267 | apple_acl == [(Allow, "role:owner", All)] 268 | 269 | if has_permission(user_principals, "eat", apple_acl): 270 | print "Yum!" 271 | ``` 272 | 273 | The other function provided is ```list_permissions(user_principals, resource)``` this will return a dict of all available permissions and a boolean value if the permission is granted or denied: 274 | 275 | ```python 276 | from fastapi_permissions import list_permissions, Allow, All 277 | 278 | user_principals == [Everyone, Authenticated, "role:owner", "user:bob"] 279 | apple_acl == [(Allow, "role:owner", All)] 280 | 281 | print(list_permissions(user_principals, apple_acl)) 282 | {"permissions:*": True} 283 | ``` 284 | 285 | Please note, that ```"permissions:*"``` is the string representation of ```fastapi_permissions.All```. 286 | 287 | 288 | How it works 289 | ------------ 290 | 291 | The main work is done in the ```has_permissions()``` function, but the most interesting ones (at least for me) are the ```configure_permissions()``` and ```permission_dependency_factory()``` functions. 292 | 293 | Wait. I didn't tell you about the latter one? 294 | 295 | The ```permission()``` thingy used in the path operation definition before is actually the mentioned ```permission_dependency_factory()```. The ```configure_permissions()``` function just provisiones it with some default values using ```functools.partial```. This reduces the function signature from ```permission_dependency_factory(permission, resource, active_principals_func, permission_exception)``` down to ```partial_function(permission, resource)```. 296 | 297 | The ```permission_dependency_factory``` returns another function with the signature ```permission_dependency(Depends(resource), Depends(active_principals_func))```. This is the acutal signature, that ```Depends()``` uses in the path operation definition to search and inject the dependencies. The rest is just some closure magic ;-). 298 | 299 | Or in other words: to have a nice API, the ```Depends()``` in the path operation function should only have a function signature for retrieving the active user and the resource. On the other side, when writing the code, I wanted to only specifiy the parts relevant to the path operation function: the resource and the permission. The rest is just on how to make it work. 300 | 301 | 302 | (F.)A.Q. 303 | -------- 304 | 305 | ### Permission check on collection of resources 306 | 307 | How to use the library with something like this: ```List[Item]=Permission("edit", get_items)```. 308 | The question was actually issue #3 and I have written a longer answer in the issue, please have a look there. 309 | 310 | 311 | Dev & Test virtual environment 312 | ------------------------------ 313 | 314 | There is an easy to use make command for setting up a virtual environment, installing the required packages and installing the project in an editable way. 315 | 316 | ``` 317 | $ git clone https://github.com/holgi/fastapi-permissions.git 318 | $ cd fastapi-permissions 319 | $ make devenv 320 | $ source .venv/bin/activate 321 | ``` 322 | 323 | Then you can test any changes locally with ```make test```. This will stop 324 | on the first error and not report coverage. 325 | 326 | ``` 327 | (permissions) $ make test 328 | ``` 329 | 330 | If you can also run all tests and get a coverage report with 331 | 332 | ``` 333 | (permissions) $ make coverage 334 | ``` 335 | 336 | And when ready to test everything as an installed package (bonus point if 337 | using ```make clean``` before) 338 | 339 | ``` 340 | (permissions) $ make tox 341 | ``` 342 | 343 | 344 | Thanks 345 | ------ 346 | - Sebastián Ramírez, for creating FastAPI 347 | - William, for fixing my stupid bug 348 | 349 | 350 | [FastApi]: https://fastapi.tiangolo.com/ 351 | [dependency]: https://fastapi.tiangolo.com/tutorial/dependencies/first-steps/ 352 | [pyramid]: https://trypyramid.com 353 | [pyramid_security]: https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/security.html 354 | [scopes]: https://fastapi.tiangolo.com/tutorial/security/oauth2-scopes/ 355 | -------------------------------------------------------------------------------- /fastapi_permissions/__init__.py: -------------------------------------------------------------------------------- 1 | """ Row Level Permissions for FastAPI 2 | 3 | This module provides an implementation for row level permissions for the 4 | FastAPI framework. This is heavily inspired / ripped off the Pyramids Web 5 | Framework, so all cudos to them! 6 | 7 | extremely simple and incomplete example: 8 | 9 | from fastapi import Depends, FastAPI 10 | from fastapi.security import OAuth2PasswordBearer 11 | from fastapi_permissions import configure_permissions, Allow, Deny 12 | from pydantic import BaseModel 13 | 14 | app = FastAPI() 15 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") 16 | 17 | class Item(BaseModel): 18 | name: str 19 | owner: str 20 | 21 | def __acl__(self): 22 | return [ 23 | (Allow, Authenticated, "view"), 24 | (Allow, "role:admin", "edit"), 25 | (Allow, f"user:{self.owner}", "delete"), 26 | ] 27 | 28 | class User(BaseModel): 29 | name: str 30 | 31 | def principals(self): 32 | return [f"user:{self.name}"] 33 | 34 | def get_current_user(token: str = Depends(oauth2_scheme)): 35 | ... 36 | 37 | def get_active_user_principals(user:User = Depends(get_current_user)): 38 | ... 39 | 40 | def get_item(item_identifier): 41 | ... 42 | 43 | # Permission is already wrapped in Depends() 44 | Permissions = configure_permissions(get_active_user_principals) 45 | 46 | @app.get("/item/{item_identifier}") 47 | async def show_item(item:Item = Permission("view", get_item)): 48 | return [{"item": item}] 49 | """ 50 | 51 | __version__ = "0.2.7" 52 | 53 | import functools 54 | import itertools 55 | from typing import Any 56 | 57 | from fastapi import Depends, HTTPException 58 | from starlette.status import HTTP_403_FORBIDDEN 59 | 60 | # constants 61 | 62 | Allow = "Allow" # acl "allow" action 63 | Deny = "Deny" # acl "deny" action 64 | 65 | Everyone = "system:everyone" # user principal for everyone 66 | Authenticated = "system:authenticated" # authenticated user principal 67 | 68 | 69 | class _AllPermissions: 70 | """ special container class for the all permissions constant 71 | 72 | first try was to override the __contains__ method of a str instance, 73 | but it turns out to be readonly... 74 | """ 75 | 76 | def __contains__(self, other): 77 | """ returns alway true any permission """ 78 | return True 79 | 80 | def __str__(self): 81 | """ string representation """ 82 | return "permissions:*" 83 | 84 | 85 | All = _AllPermissions() 86 | 87 | 88 | DENY_ALL = (Deny, Everyone, All) # acl shorthand, denies anything 89 | ALOW_ALL = (Allow, Everyone, All) # acl shorthand, allows everything 90 | 91 | 92 | # the exception that will be raised, if no sufficient permissions are found 93 | # can be configured in the configure_permissions() function 94 | permission_exception = HTTPException( 95 | status_code=HTTP_403_FORBIDDEN, 96 | detail="Insufficient permissions", 97 | headers={"WWW-Authenticate": "Bearer"}, 98 | ) 99 | 100 | 101 | def configure_permissions( 102 | active_principals_func: Any, 103 | permission_exception: HTTPException = permission_exception, 104 | ): 105 | """ sets the basic configuration for the permissions system 106 | 107 | active_principals_func: 108 | a dependency that returns the principals of the current active user 109 | permission_exception: 110 | the exception used if a permission is denied 111 | 112 | returns: permission_dependency_factory function, 113 | with some parameters already provisioned 114 | """ 115 | active_principals_func = Depends(active_principals_func) 116 | 117 | return functools.partial( 118 | permission_dependency_factory, 119 | active_principals_func=active_principals_func, 120 | permission_exception=permission_exception, 121 | ) 122 | 123 | 124 | def permission_dependency_factory( 125 | permission: str, 126 | resource: Any, 127 | active_principals_func: Any, 128 | permission_exception: HTTPException, 129 | ): 130 | """ returns a function that acts as a dependable for checking permissions 131 | 132 | This is the actual function used for creating the permission dependency, 133 | with the help of fucntools.partial in the "configure_permissions()" 134 | function. 135 | 136 | permission: 137 | the permission to check 138 | resource: 139 | the resource that will be accessed 140 | active_principals_func (provisioned by configure_permissions): 141 | a dependency that returns the principals of the current active user 142 | permission_exception (provisioned by configure_permissions): 143 | exception if permission is denied 144 | 145 | returns: dependency function for "Depends()" 146 | """ 147 | if callable(resource): 148 | dependable_resource = Depends(resource) 149 | else: 150 | dependable_resource = Depends(lambda: resource) 151 | 152 | # to get the caller signature right, we need to add only the resource and 153 | # user dependable in the definition 154 | # the permission itself is available through the outer function scope 155 | def permission_dependency( 156 | resource=dependable_resource, principals=active_principals_func 157 | ): 158 | if has_permission(principals, permission, resource): 159 | return resource 160 | raise permission_exception 161 | 162 | return Depends(permission_dependency) 163 | 164 | 165 | def has_permission( 166 | user_principals: list, requested_permission: str, resource: Any 167 | ): 168 | """ checks if a user has the permission for a resource 169 | 170 | The order of the function parameters can be remembered like "Joe eat apple" 171 | 172 | user_principals: the principals of a user 173 | requested_permission: the permission that should be checked 174 | resource: the object the user wants to access, must provide an ACL 175 | 176 | returns bool: permission granted or denied 177 | """ 178 | acl = normalize_acl(resource) 179 | 180 | for action, principal, permissions in acl: 181 | if isinstance(permissions, str): 182 | permissions = {permissions} 183 | if requested_permission in permissions: 184 | if principal in user_principals: 185 | return action == Allow 186 | return False 187 | 188 | 189 | def list_permissions(user_principals: list, resource: Any): 190 | """ lists all permissions of a user for a resouce 191 | 192 | user_principals: the principals of a user 193 | resource: the object the user wants to access, must provide an ACL 194 | 195 | returns dict: every available permission of the resource as key 196 | and True / False as value if the permission is granted. 197 | """ 198 | acl = normalize_acl(resource) 199 | 200 | acl_permissions = (permissions for _, _, permissions in acl) 201 | as_iterables = ({p} if not is_like_list(p) else p for p in acl_permissions) 202 | permissions = set(itertools.chain.from_iterable(as_iterables)) 203 | 204 | return { 205 | str(p): has_permission(user_principals, p, acl) for p in permissions 206 | } 207 | 208 | 209 | # utility functions 210 | 211 | 212 | def normalize_acl(resource: Any): 213 | """ returns the access controll list for a resource 214 | 215 | If the resource is not an acl list itself it needs to have an "__acl__" 216 | attribute. If the "__acl__" attribute is a callable, it will be called and 217 | the result of the call returned. 218 | 219 | An existing __acl__ attribute takes precedence before checking if it is an 220 | iterable. 221 | """ 222 | acl = getattr(resource, "__acl__", None) 223 | if callable(acl): 224 | return acl() 225 | elif acl is not None: 226 | return acl 227 | elif is_like_list(resource): 228 | return resource 229 | return [] 230 | 231 | 232 | def is_like_list(something): 233 | """ checks if something is iterable but not a string """ 234 | if isinstance(something, str): 235 | return False 236 | return hasattr(something, "__iter__") 237 | -------------------------------------------------------------------------------- /fastapi_permissions/example.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import List 3 | 4 | import jwt 5 | from fastapi import Depends, FastAPI, HTTPException 6 | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 7 | from jwt import PyJWTError 8 | from passlib.context import CryptContext 9 | from pydantic import BaseModel, ValidationError 10 | from starlette.status import HTTP_401_UNAUTHORIZED 11 | 12 | from fastapi_permissions import ( 13 | Allow, 14 | Authenticated, 15 | Deny, 16 | Everyone, 17 | configure_permissions, 18 | list_permissions, 19 | ) 20 | 21 | # >>> THIS IS NEW 22 | 23 | # import of the new "permission" module for row level permissions 24 | 25 | 26 | # <<< 27 | 28 | 29 | # to get a string like this run: 30 | # openssl rand -hex 32 31 | SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" 32 | ALGORITHM = "HS256" 33 | ACCESS_TOKEN_EXPIRE_MINUTES = 30 34 | 35 | 36 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 37 | 38 | # >>> THIS IS NEW 39 | 40 | # users get a new field "principals", that contains a list with 41 | # roles and other identifiers for the user 42 | 43 | # <<< 44 | 45 | fake_users_db = { 46 | "bob": { 47 | "username": "bob", 48 | "full_name": "Bobby Bob", 49 | "email": "bob@example.com", 50 | "hashed_password": pwd_context.hash("secret"), 51 | # >>> THIS IS NEW 52 | "principals": ["user:bob", "role:admin"], 53 | # <<< 54 | }, 55 | "alice": { 56 | "username": "alice", 57 | "full_name": "Alice Chains", 58 | "email": "alicechains@example.com", 59 | "hashed_password": pwd_context.hash("secret"), 60 | # >>> THIS IS NEW 61 | "principals": ["user:alice"], 62 | # <<< 63 | }, 64 | } 65 | 66 | 67 | class Token(BaseModel): 68 | access_token: str 69 | token_type: str 70 | 71 | 72 | class TokenData(BaseModel): 73 | username: str = None 74 | 75 | 76 | class User(BaseModel): 77 | username: str 78 | email: str = None 79 | full_name: str = None 80 | 81 | # >>> THIS IS NEW 82 | # just reflects the changes in the fake_user_db 83 | principals: List[str] = [] 84 | # <<< 85 | 86 | 87 | class UserInDB(User): 88 | hashed_password: str 89 | 90 | 91 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") 92 | 93 | app = FastAPI() 94 | 95 | 96 | def verify_password(plain_password, hashed_password): 97 | return pwd_context.verify(plain_password, hashed_password) 98 | 99 | 100 | def get_user(db, username: str): 101 | if username in db: 102 | user_dict = db[username] 103 | return UserInDB(**user_dict) 104 | 105 | 106 | def get_item(item_id: int): 107 | if item_id in fake_items_db: 108 | item_dict = fake_items_db[item_id] 109 | return Item(**item_dict) 110 | 111 | 112 | def authenticate_user(fake_db, username: str, password: str): 113 | user = get_user(fake_db, username) 114 | if not user: 115 | return False 116 | if not verify_password(password, user.hashed_password): 117 | return False 118 | return user 119 | 120 | 121 | def create_access_token(*, data: dict, expires_delta: timedelta): 122 | to_encode = data.copy() 123 | expire = datetime.utcnow() + expires_delta 124 | to_encode.update({"exp": expire}) 125 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 126 | return encoded_jwt 127 | 128 | 129 | async def get_current_user(token: str = Depends(oauth2_scheme)): 130 | credentials_exception = HTTPException( 131 | status_code=HTTP_401_UNAUTHORIZED, 132 | detail="Could not validate credentials", 133 | headers={"WWW-Authenticate": "Bearer"}, 134 | ) 135 | try: 136 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 137 | username: str = payload.get("sub") 138 | if username is None: 139 | raise credentials_exception 140 | except (PyJWTError, ValidationError): 141 | raise credentials_exception 142 | user = get_user(fake_users_db, username=username) 143 | if user is None: 144 | raise credentials_exception 145 | return user 146 | 147 | 148 | # >>> THIS IS NEW 149 | 150 | # a fake database for some cheesy items 151 | 152 | fake_items_db = { 153 | 1: {"name": "Stilton", "owner": "bob"}, 154 | 2: {"name": "Danish Blue", "owner": "alice"}, 155 | } 156 | 157 | 158 | # the model class for the items most important is the __acl__ method 159 | 160 | 161 | class Item(BaseModel): 162 | name: str 163 | owner: str 164 | 165 | def __acl__(self): 166 | """ defines who can do what to the model instance 167 | 168 | the function returns a list containing tuples in the form of 169 | (Allow or Deny, principal identifier, permission name) 170 | 171 | If a role is not listed (like "role:user") the access will be 172 | automatically deny. It's like a (Deny, Everyone, All) is automatically 173 | appended at the end. 174 | """ 175 | return [ 176 | (Allow, Authenticated, "view"), 177 | (Allow, "role:admin", "use"), 178 | (Allow, f"user:{self.owner}", "use"), 179 | ] 180 | 181 | 182 | # for resources that don't have a corresponding model in the database 183 | # a simple class with an "__acl__" property is defined 184 | 185 | 186 | class ItemListResource: 187 | __acl__ = [(Allow, Authenticated, "view")] 188 | 189 | 190 | # you can even use just a list 191 | 192 | NewItemAcl = [(Deny, "user:bob", "create"), (Allow, Authenticated, "create")] 193 | 194 | 195 | # the current user is determined by the "get_current_user" function. 196 | # but the permissions system is not interested in the user itself, but in the 197 | # associated principals. 198 | 199 | 200 | def get_active_principals(user: User = Depends(get_current_user)): 201 | if user: 202 | # user is logged in 203 | principals = [Everyone, Authenticated] 204 | principals.extend(getattr(user, "principals", [])) 205 | else: 206 | # user is not logged in 207 | principals = [Everyone] 208 | return principals 209 | 210 | 211 | # We need to tell the permissions system, how to get the principals of the 212 | # active user. 213 | # 214 | # "configure_permissions" returns a function that will return another function 215 | # that can act as a dependable. Confusing? Propably, but easy to use. 216 | 217 | Permission = configure_permissions(get_active_principals) 218 | 219 | # <<< 220 | 221 | 222 | @app.post("/token", response_model=Token) 223 | async def login_for_access_token( 224 | form_data: OAuth2PasswordRequestForm = Depends(), 225 | ): 226 | user = authenticate_user( 227 | fake_users_db, form_data.username, form_data.password 228 | ) 229 | if not user: 230 | raise HTTPException( 231 | status_code=400, detail="Incorrect username or password" 232 | ) 233 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 234 | access_token = create_access_token( 235 | data={"sub": user.username}, expires_delta=access_token_expires 236 | ) 237 | return {"access_token": access_token, "token_type": "bearer"} 238 | 239 | 240 | @app.get("/me/", response_model=User) 241 | async def read_users_me(current_user: User = Depends(get_current_user)): 242 | return current_user 243 | 244 | 245 | # >>> THIS IS NEW 246 | 247 | # The most interesting part here is Permission("view", ItemListResource)" 248 | # This function call will return a function that acts as a dependable 249 | 250 | # If the currently logged in user has the permission "view" for the 251 | # ItemListResource, the resource will be returned 252 | 253 | # If the user does not have the proper permission, a HTTP_401_UNAUTHORIZED 254 | # exception will be raised 255 | 256 | # permission result for the fake users: 257 | # - bob: granted 258 | # - alice: granted 259 | 260 | 261 | @app.get("/items/") 262 | async def show_items( 263 | ilr: ItemListResource = Permission("view", ItemListResource), 264 | user=Depends(get_current_user), 265 | ): 266 | available_permissions = { 267 | index: list_permissions(user.principals, get_item(index)) 268 | for index in fake_items_db 269 | } 270 | return [ 271 | { 272 | "items": fake_items_db, 273 | "available_permissions": available_permissions, 274 | } 275 | ] 276 | 277 | 278 | # permission result for the fake users: 279 | # - bob: denied 280 | # - alice: granted 281 | 282 | 283 | @app.get("/item/add") 284 | async def add_items(acls: list = Permission("create", NewItemAcl)): 285 | return [{"items": "I can haz cheese?"}] 286 | 287 | 288 | # here is the second interesting thing: instead of using a resource class, 289 | # a dependable can be used. This way, we can easily acces database entries 290 | 291 | # permission result for the fake users: 292 | # - bob: item 1: granted, item 2: granted 293 | # - alice: item 1: granted, item 2: granted 294 | 295 | 296 | @app.get("/item/{item_id}") 297 | async def show_item(item: Item = Permission("view", get_item)): 298 | return [{"item": item}] 299 | 300 | 301 | # permission result for the fake users: 302 | # - bob: item 1: granted, item 2: granted 303 | # - alice: item 1: DENIED, item 2: granted 304 | 305 | 306 | @app.get("/item/{item_id}/use") 307 | async def use_item(item: Item = Permission("use", get_item)): 308 | return [{"item": item}] 309 | 310 | 311 | # <<< 312 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit"] 3 | build-backend = "flit.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "fastapi_permissions" 7 | author = "Holger Frey" 8 | author-email = "mail@holgerfrey.de" 9 | home-page = "https://github.com/holgi/fastapi-permissions" 10 | description-file = "README.md" 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Intended Audience :: Developers", 14 | "License :: Freely Distributable", 15 | "Programming Language :: Python :: 3.7", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 19 | ] 20 | requires = [ 21 | "fastapi >= 0.33.0", 22 | "python-multipart >= 0.0.5", 23 | ] 24 | requires-python = ">=3.6" 25 | 26 | [tool.flit.metadata.requires-extra] 27 | test = [ 28 | "pytest >= 4.0.0", 29 | "pytest-cov", 30 | "pytest-mock", 31 | "pytest-asyncio", 32 | "pytest-randomly", 33 | "tox", 34 | ] 35 | dev = [ 36 | "black", 37 | "flake8", 38 | "flake8-comprehensions", 39 | "isort >= 5.0.0", 40 | "keyring", 41 | "pre-commit", 42 | "pyjwt", 43 | "passlib[bcrypt]", 44 | "fastapi[all]", 45 | ] 46 | 47 | [tool.black] 48 | line-length = 79 49 | py37 = true 50 | include = '\.pyi?$' 51 | exclude = ''' 52 | /( 53 | \.git 54 | | \.tox 55 | | \.venv 56 | | build 57 | | dist 58 | )/ 59 | ''' 60 | 61 | [tool.isort] 62 | line_length=79 63 | multi_line_output=3 64 | length_sort="True" 65 | float_to_top="True" 66 | include_trailing_comma="True" 67 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ test for fastapi_permissions """ 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.testclient import TestClient 3 | 4 | 5 | @pytest.fixture 6 | def client(): 7 | from fastapi_permissions.example import app 8 | 9 | return TestClient(app) 10 | 11 | 12 | @pytest.fixture() 13 | def example_app_openapi(client): 14 | response = client.get("/openapi.json") 15 | return response.json() 16 | -------------------------------------------------------------------------------- /tests/test_all_constant.py: -------------------------------------------------------------------------------- 1 | """ tests for the _AllPermission class and All constant """ 2 | 3 | 4 | def test_all_instance(): 5 | """ is the "All" constant an instance of "_AllPermissions" """ 6 | from fastapi_permissions import All, _AllPermissions 7 | 8 | assert isinstance(All, _AllPermissions) 9 | 10 | 11 | def test_all_permissions_contains(): 12 | """ does "All" contain everything """ 13 | from fastapi_permissions import All 14 | 15 | for something in [True, False, None, "string", [], {}, 1, All]: 16 | assert something in All 17 | 18 | 19 | def test_all_permission_string(): 20 | """ test the string representation of the "All" constan """ 21 | from fastapi_permissions import All 22 | 23 | assert str(All) == "permissions:*" 24 | -------------------------------------------------------------------------------- /tests/test_example_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def get_with_user(url, username, client): 5 | response = client.post( 6 | "/token", data={"username": username, "password": "secret"} 7 | ) 8 | data = response.json() 9 | headers = {"Authorization": "Bearer " + data["access_token"]} 10 | return client.get(url, headers=headers) 11 | 12 | 13 | @pytest.mark.parametrize("username", ["alice", "bob"]) 14 | def test_app_get_token_valid_credentials(username, client): 15 | """ test the example login with valid credentials """ 16 | response = client.post( 17 | "/token", data={"username": username, "password": "secret"} 18 | ) 19 | print(response.text) 20 | assert response.status_code == 200 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "username, password", 25 | [ 26 | ("bob", "wrong password"), 27 | ("bob", ""), 28 | ("wrong user", "secret"), 29 | ("", "secret"), 30 | ("", ""), 31 | ("wrong user", "wrong password"), 32 | ], 33 | ) 34 | def test_app_get_token_bad_credentials(username, password, client): 35 | """ test the example login with invalid credentials """ 36 | response = client.post( 37 | "/token", data={"username": username, "password": password} 38 | ) 39 | assert response.status_code >= 400 40 | 41 | 42 | @pytest.mark.parametrize("username", ["alice", "bob"]) 43 | def test_app_get_me(username, client): 44 | """ test if a logged in user can access a restricted url """ 45 | response = get_with_user("/me/", username, client) 46 | assert response.status_code == 200 47 | data = response.json() 48 | assert data["username"] == username 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "url, username, granted", 53 | [ 54 | ("/items/", "bob", True), 55 | ("/items/", "alice", True), 56 | ("/item/add", "bob", False), 57 | ("/item/add", "alice", True), 58 | ("/item/1", "bob", True), 59 | ("/item/1", "alice", True), 60 | ("/item/2", "bob", True), 61 | ("/item/2", "alice", True), 62 | ("/item/1/use", "bob", True), 63 | ("/item/1/use", "alice", False), 64 | ("/item/2/use", "bob", True), 65 | ("/item/2/use", "alice", True), 66 | ], 67 | ) 68 | def test_app_permissions(url, username, granted, client): 69 | """ test urls protected by principals, permissions and acls """ 70 | response = get_with_user(url, username, client) 71 | assert response.status_code == 200 if granted else 403 72 | 73 | 74 | # the following tests are only here to get to a high coverage rate 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_app_no_token_subject(): 79 | """ raise an error if no subject is specified in login token """ 80 | from datetime import timedelta 81 | 82 | from fastapi import HTTPException 83 | 84 | from fastapi_permissions.example import ( 85 | get_current_user, 86 | create_access_token, 87 | ) 88 | 89 | token = create_access_token(data={}, expires_delta=timedelta(minutes=5)) 90 | 91 | with pytest.raises(HTTPException): 92 | await get_current_user(token) 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_app_token_with_fake_user(): 97 | """ raise an error if an invalid subject is specified in login token """ 98 | from datetime import timedelta 99 | 100 | from fastapi import HTTPException 101 | 102 | from fastapi_permissions.example import ( 103 | get_current_user, 104 | create_access_token, 105 | ) 106 | 107 | token = create_access_token( 108 | data={"sub": "unknown"}, expires_delta=timedelta(minutes=5) 109 | ) 110 | 111 | with pytest.raises(HTTPException): 112 | await get_current_user(token) 113 | 114 | 115 | @pytest.mark.asyncio 116 | async def test_app_modified_token(): 117 | """ raise an error if login token was modified """ 118 | from datetime import timedelta 119 | 120 | from fastapi import HTTPException 121 | 122 | from fastapi_permissions.example import ( 123 | get_current_user, 124 | create_access_token, 125 | ) 126 | 127 | token = create_access_token(data={}, expires_delta=timedelta(minutes=5)) 128 | 129 | with pytest.raises(HTTPException): 130 | await get_current_user(token[:-1]) 131 | 132 | 133 | @pytest.mark.asyncio 134 | async def test_app_add_items_would_return_correct_value(): 135 | """ add_items will return the correct value if someone had permission """ 136 | from fastapi_permissions.example import add_items 137 | 138 | result = await add_items([]) 139 | assert result == [{"items": "I can haz cheese?"}] 140 | 141 | 142 | def test_get_active_principals_for_not_logged_in_user(): 143 | """ return the correct principals for a non logged in user """ 144 | from fastapi_permissions.example import Everyone, get_active_principals 145 | 146 | result = get_active_principals(None) 147 | assert result == [Everyone] 148 | -------------------------------------------------------------------------------- /tests/test_example_openapi_specs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | SECURITY_SPEC = [{"OAuth2PasswordBearer": []}] 4 | 5 | ITEM_ADD_SPECS = { 6 | "parameters": None, 7 | "responses": { 8 | "200": { 9 | "content": {"application/json": {"schema": {}}}, 10 | "description": "Successful Response", 11 | } 12 | }, 13 | } 14 | 15 | ITEM_SPECS = { 16 | "parameters": [ 17 | { 18 | "in": "path", 19 | "name": "item_id", 20 | "required": True, 21 | "schema": {"title": "Item Id", "type": "integer"}, 22 | } 23 | ], 24 | "responses": { 25 | "200": { 26 | "content": {"application/json": {"schema": {}}}, 27 | "description": "Successful Response", 28 | }, 29 | "422": { 30 | "content": { 31 | "application/json": { 32 | "schema": { 33 | "$ref": "#/components/schemas/HTTPValidationError" 34 | } 35 | } 36 | }, 37 | "description": "Validation Error", 38 | }, 39 | }, 40 | } 41 | 42 | 43 | ITEM_USE_SPECS = { 44 | "parameters": [ 45 | { 46 | "in": "path", 47 | "name": "item_id", 48 | "required": True, 49 | "schema": {"title": "Item Id", "type": "integer"}, 50 | } 51 | ], 52 | "responses": { 53 | "200": { 54 | "content": {"application/json": {"schema": {}}}, 55 | "description": "Successful Response", 56 | }, 57 | "422": { 58 | "content": { 59 | "application/json": { 60 | "schema": { 61 | "$ref": "#/components/schemas/HTTPValidationError" 62 | } 63 | } 64 | }, 65 | "description": "Validation Error", 66 | }, 67 | }, 68 | } 69 | 70 | 71 | ITEMS_SPECS = { 72 | "parameters": None, 73 | "responses": { 74 | "200": { 75 | "content": {"application/json": {"schema": {}}}, 76 | "description": "Successful Response", 77 | } 78 | }, 79 | } 80 | 81 | ME_SPECS = { 82 | "parameters": None, 83 | "responses": { 84 | "200": { 85 | "content": { 86 | "application/json": { 87 | "schema": {"$ref": "#/components/schemas/User"} 88 | } 89 | }, 90 | "description": "Successful Response", 91 | } 92 | }, 93 | } 94 | 95 | 96 | def test_example_open_api_paths(example_app_openapi): 97 | """ test if the openapi paths match """ 98 | 99 | expected = { 100 | "/item/add", 101 | "/item/{item_id}", 102 | "/item/{item_id}/use", 103 | "/items/", 104 | "/me/", 105 | "/token", 106 | } 107 | 108 | paths = example_app_openapi["paths"].keys() 109 | 110 | assert set(paths) == expected 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "path,expected", 115 | [ 116 | ("/item/add", ITEM_ADD_SPECS), 117 | ("/item/{item_id}", ITEM_SPECS), 118 | ("/item/{item_id}/use", ITEM_USE_SPECS), 119 | ("/items/", ITEMS_SPECS), 120 | ("/me/", ME_SPECS), 121 | ], 122 | ) 123 | def test_example_open_api_specs(example_app_openapi, path, expected): 124 | """ test some specs of openapi paths """ 125 | 126 | openapi_path_spec = example_app_openapi["paths"][path]["get"] 127 | 128 | assert openapi_path_spec.get("parameters", None) == expected["parameters"] 129 | assert openapi_path_spec.get("responses", None) == expected["responses"] 130 | assert openapi_path_spec.get("security", None) == SECURITY_SPEC 131 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | """ Tests the main api functions """ 2 | 3 | import inspect 4 | 5 | import pytest 6 | 7 | 8 | def dummy_principal_callable(): 9 | return "dummy principals" 10 | 11 | 12 | def dummy_resource_callable(): 13 | return "dummy resource" 14 | 15 | 16 | class DummyUser: 17 | def __init__(self, principals): 18 | from fastapi_permissions import Everyone, Authenticated 19 | 20 | self.principals = [Everyone] + principals 21 | if principals: 22 | self.principals.append(Authenticated) 23 | 24 | def __repr__(self): 25 | return self.principals[0] 26 | 27 | 28 | dummy_user_john = DummyUser(["user:john", "role:user"]) 29 | dummy_user_jane = DummyUser(["user:jane", "role:user", "role:moderator"]) 30 | dummy_user_alice = DummyUser(["user:alice", "role:admin"]) 31 | dummy_user_bob = DummyUser([]) 32 | 33 | 34 | @pytest.fixture 35 | def acl_fixture(): 36 | from fastapi_permissions import All, Deny, Allow, Everyone, Authenticated 37 | 38 | yield [ 39 | (Allow, "user:john", "view"), 40 | (Allow, "user:john", "edit"), 41 | (Allow, "user:jane", ("edit", "use")), 42 | (Deny, "role:user", "create"), 43 | (Allow, "role:moderator", "delete"), 44 | (Deny, Authenticated, "copy"), 45 | (Allow, "role:admin", All), 46 | (Allow, Everyone, "share"), 47 | (Allow, "role:moderator", "share"), 48 | ] 49 | 50 | 51 | permission_results = { 52 | dummy_user_john: { 53 | "view": True, 54 | "edit": True, 55 | "use": False, 56 | "create": False, 57 | "delete": False, 58 | "share": True, 59 | "copy": False, 60 | "permissions:*": False, 61 | }, 62 | dummy_user_jane: { 63 | "view": False, 64 | "edit": True, 65 | "use": True, 66 | "create": False, 67 | "delete": True, 68 | "share": True, 69 | "copy": False, 70 | "permissions:*": False, 71 | }, 72 | dummy_user_alice: { 73 | "view": True, 74 | "edit": True, 75 | "use": True, 76 | "create": True, 77 | "delete": True, 78 | "share": True, 79 | "copy": False, 80 | "permissions:*": True, 81 | }, 82 | dummy_user_bob: { 83 | "view": False, 84 | "edit": False, 85 | "use": False, 86 | "create": False, 87 | "delete": False, 88 | "share": True, 89 | "copy": False, 90 | "permissions:*": False, 91 | }, 92 | } 93 | 94 | 95 | def test_configure_permissions_wraps_principal_callable(mocker): 96 | """ test if active_principle_funcs parameter is wrapped in "Depends" """ 97 | 98 | mocker.patch("fastapi_permissions.Depends") 99 | 100 | from fastapi_permissions import Depends, configure_permissions 101 | 102 | configure_permissions(dummy_principal_callable) 103 | 104 | assert Depends.call_count == 1 105 | assert Depends.call_args == mocker.call(dummy_principal_callable) 106 | 107 | 108 | def test_configure_permissions_returns_correct_signature(mocker): 109 | """ check the return value signature of configure_permissions """ 110 | 111 | mocker.patch("fastapi_permissions.Depends") 112 | 113 | from fastapi_permissions import ( 114 | Depends, 115 | permission_exception, 116 | configure_permissions, 117 | permission_dependency_factory, 118 | ) 119 | 120 | partial_func = configure_permissions(dummy_principal_callable) 121 | parameters = inspect.signature(partial_func).parameters 122 | 123 | assert partial_func.func == permission_dependency_factory 124 | assert len(parameters) == 4 125 | assert parameters["permission"].default == inspect.Parameter.empty 126 | assert parameters["resource"].default == inspect.Parameter.empty 127 | assert parameters["active_principals_func"].default == Depends( 128 | dummy_principal_callable 129 | ) 130 | assert parameters["permission_exception"].default == permission_exception 131 | 132 | 133 | def test_configure_permissions_parameters(mocker): 134 | """ test the configuration options of configure_permissions """ 135 | 136 | mocker.patch("fastapi_permissions.Depends") 137 | 138 | from fastapi_permissions import configure_permissions 139 | 140 | partial_func = configure_permissions( 141 | dummy_principal_callable, permission_exception="exception option" 142 | ) 143 | parameters = inspect.signature(partial_func).parameters 144 | 145 | assert parameters["permission_exception"].default == "exception option" 146 | 147 | 148 | def test_permission_dependency_factory_wraps_callable_resource(mocker): 149 | mocker.patch("fastapi_permissions.Depends") 150 | 151 | from fastapi_permissions import Depends, permission_dependency_factory 152 | 153 | permission_dependency_factory( 154 | "view", 155 | dummy_resource_callable, 156 | "active_principals_func", 157 | "permisssion_exception", 158 | ) 159 | 160 | assert Depends.call_count == 2 161 | assert Depends.call_args_list[0] == mocker.call(dummy_resource_callable) 162 | 163 | 164 | def test_permission_dependency_factory_returns_correct_signature(mocker): 165 | mocker.patch("fastapi_permissions.Depends") 166 | 167 | from fastapi_permissions import Depends, permission_dependency_factory 168 | 169 | permission_func = permission_dependency_factory( 170 | "view", 171 | dummy_resource_callable, 172 | "active_principals_func", 173 | "permisssion_exception", 174 | ) 175 | 176 | assert Depends.call_count == 2 177 | args, kwargs = Depends.call_args_list[1] 178 | permission_func = args[0] 179 | assert callable(permission_func) 180 | 181 | parameters = inspect.signature(permission_func).parameters 182 | print(parameters) 183 | assert len(parameters) == 2 184 | assert parameters["resource"].default == Depends(dummy_resource_callable) 185 | assert parameters["principals"].default == "active_principals_func" 186 | 187 | 188 | def test_permission_dependency_returns_requested_resource(mocker): 189 | """ If a user has a permission, the resource should be returned """ 190 | mocker.patch("fastapi_permissions.has_permission", return_value=True) 191 | mocker.patch("fastapi_permissions.Depends") 192 | 193 | from fastapi_permissions import Depends, permission_dependency_factory 194 | 195 | # since the resulting permission function is wrapped in Depends() 196 | # we need to extract it from the mock 197 | permission_dependency_factory( 198 | "view", 199 | dummy_resource_callable, 200 | "active_principals_func", 201 | "permisssion_exception", 202 | ) 203 | assert Depends.call_count == 2 204 | args, kwargs = Depends.call_args_list[1] 205 | permission_func = args[0] 206 | 207 | result = permission_func() 208 | assert result == Depends(dummy_resource_callable) 209 | 210 | 211 | def test_permission_dependency_raises_exception(mocker): 212 | """ If a user dosen't have a permission, a exception should be raised """ 213 | mocker.patch("fastapi_permissions.has_permission", return_value=False) 214 | mocker.patch("fastapi_permissions.Depends") 215 | 216 | from fastapi import HTTPException 217 | 218 | from fastapi_permissions import ( 219 | Depends, 220 | permission_exception, 221 | permission_dependency_factory, 222 | ) 223 | 224 | # since the resulting permission function is wrapped in Depends() 225 | # we need to extract it from the mock 226 | permission_func = permission_dependency_factory( 227 | "view", 228 | dummy_resource_callable, 229 | "active_principals_func", 230 | permission_exception, 231 | ) 232 | assert Depends.call_count == 2 233 | args, kwargs = Depends.call_args_list[1] 234 | permission_func = args[0] 235 | 236 | with pytest.raises(HTTPException): 237 | permission_func() 238 | 239 | 240 | @pytest.mark.parametrize( 241 | "user", 242 | [dummy_user_john, dummy_user_jane, dummy_user_alice, dummy_user_bob], 243 | ) 244 | @pytest.mark.parametrize( 245 | "permission", 246 | ["view", "edit", "use", "create", "delete", "share", "copy", "nuke"], 247 | ) 248 | def test_has_permission(user, permission, acl_fixture): 249 | """ tests the has_permission function """ 250 | from fastapi_permissions import has_permission 251 | 252 | result = has_permission(user.principals, permission, acl_fixture) 253 | 254 | key = "permissions:*" if permission == "nuke" else permission 255 | assert result == permission_results[user][key] 256 | 257 | 258 | @pytest.mark.parametrize( 259 | "user", 260 | [dummy_user_john, dummy_user_jane, dummy_user_alice, dummy_user_bob], 261 | ) 262 | def test_list_permissions(user, acl_fixture): 263 | """ tests the list_permissions function """ 264 | from fastapi_permissions import list_permissions 265 | 266 | result = list_permissions(user.principals, acl_fixture) 267 | 268 | assert result == permission_results[user] 269 | -------------------------------------------------------------------------------- /tests/test_utility_functions.py: -------------------------------------------------------------------------------- 1 | """ tests for the utility functions """ 2 | 3 | import pytest 4 | 5 | 6 | class DummyUser: 7 | def __init__(self, principals): 8 | self.principals = principals 9 | 10 | 11 | class DummyResource: 12 | def __init__(self, acl): 13 | self.__acl__ = acl 14 | 15 | 16 | @pytest.mark.parametrize("iterable", [[], (), {}, set()]) 17 | def test_normalize_acl_list_provided(iterable): 18 | """ test for acl provided directly as an iterable """ 19 | from fastapi_permissions import normalize_acl 20 | 21 | assert normalize_acl(iterable) == iterable 22 | 23 | 24 | def test_normalize_acl_without_acl_attribute(): 25 | """ test for resource without __acl__ attribute """ 26 | from fastapi_permissions import normalize_acl 27 | 28 | assert normalize_acl("without __acl__") == [] 29 | 30 | 31 | def test_normalize_acl_with_acl_attribute(): 32 | """ test for resource with an __acl__ attribute """ 33 | from fastapi_permissions import normalize_acl 34 | 35 | resource = DummyResource("acl definition") 36 | 37 | assert normalize_acl(resource) == "acl definition" 38 | 39 | 40 | def test_normalize_acl_with_acl_method(): 41 | """ test for resource with an __acl__ attribute """ 42 | from fastapi_permissions import normalize_acl 43 | 44 | resource = DummyResource(lambda: "acl definition") 45 | 46 | assert normalize_acl(resource) == "acl definition" 47 | 48 | 49 | def test_normalize_acl_attribute_takes_precedence(): 50 | """ test for resource with an __acl__ attribute that are also iterables """ 51 | from fastapi_permissions import normalize_acl 52 | 53 | class DummyList(list): 54 | __acl__ = "acl definition" 55 | 56 | resource = DummyList() 57 | 58 | assert normalize_acl(resource) == "acl definition" 59 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37 3 | isolated_build = True 4 | 5 | [testenv] 6 | deps = 7 | bcrypt 8 | fastapi 9 | passlib 10 | pyjwt 11 | pytest 12 | pytest-cov 13 | pytest-mock 14 | pytest-asyncio 15 | python-multipart 16 | requests 17 | setuptools>=41.2.0 18 | pip>=20.0.2 19 | 20 | changedir = {toxinidir}/tests 21 | commands = pytest --cov=fastapi_permissions 22 | --------------------------------------------------------------------------------