├── .gitignore ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── Makefile ├── README.md ├── _config.yml ├── examples ├── README.md ├── __init__.py ├── app.cfg ├── app.py ├── app.rego ├── app_test.rego ├── data.json ├── logging.rego ├── logging_test.rego └── utils.py ├── flask_opa.py ├── hq ├── setup.cfg ├── setup.py └── tests ├── README.md ├── conftest.py └── test_opa_class.py /.gitignore: -------------------------------------------------------------------------------- 1 | #Python related files 2 | Flask_OPA.egg-info/ 3 | .eggs/ 4 | htmlcov/ 5 | build/ 6 | dist/ 7 | 8 | # Virtual Environment 9 | venv/ 10 | .venv/ 11 | 12 | # IDE files 13 | .idea/ 14 | 15 | # OS related files 16 | .DS_Store 17 | 18 | # Runtime 19 | nohup.out 20 | .Makefile.swp 21 | .env 22 | 23 | # OPA 24 | .opa_history 25 | 26 | # OPA files for testing 27 | examples/input.json 28 | 29 | # Python files 30 | __pycache__ 31 | 32 | # Code coverage 33 | .coverage -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3.6 3 | sudo: enabled 4 | before_install: 5 | - sudo apt-get update 6 | - sudo apt-get install -y git 7 | - sudo apt-get install -y curl 8 | install: make install-dev 9 | script: make build 10 | after_success: 11 | - codecov 12 | deploy: 13 | provider: pypi 14 | user: $PYPI_USERNAME 15 | password: $PYPI_PASSWORD 16 | skip_existing: true 17 | distributions: "sdist bdist_wheel" 18 | on: 19 | branch: master 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Eliecer Hernandez Garbey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Made by EliuX for OPA-Flask. 2 | 3 | .PHONY: build 4 | build: install-dev lint coverage 5 | 6 | .PHONY: start-opa 7 | start-opa: 8 | nohup opa run -s -w examples & 9 | 10 | .PHONY: stop-opa, kill 11 | stop-opa: 12 | kill $(ps | grep opa | awk '{print $1}') 13 | 14 | .PHONY: demo 15 | demo: start-opa 16 | export FLASK_ENV=development 17 | export FLASK_APP=examples/app.py 18 | flask run 19 | 20 | .PHONY: test 21 | test: 22 | pytest -v 23 | 24 | .PHONY: coverage 25 | coverage: 26 | coverage erase 27 | coverage run -m pytest -v 28 | 29 | .PHONY: lint 30 | lint: 31 | flake8 flask_opa.py --count 32 | 33 | .PHONY: install 34 | install: 35 | pip3 install -e . 36 | 37 | .PHONY: install-dev 38 | install-dev: install 39 | pip3 install --upgrade pytest 40 | pip3 install --upgrade coverage 41 | pip3 install --upgrade codecov 42 | pip3 install --upgrade flake8 43 | pip3 install --upgrade responses 44 | pip3 install --upgrade python-semantic-release 45 | 46 | .PHONY: semver-test 47 | semver-test: 48 | semantic-release version --noop 49 | 50 | .PHONY: push 51 | push: 52 | ./setup.py sdist bdist_wheel 53 | pip3 install --user --upgrade twine 54 | twine upload --repository-url https://pypi.org/legacy/ dist/* 55 | 56 | .PHONY: push-test 57 | push-test: 58 | ./setup.py sdist bdist_wheel 59 | pip3 install --user --upgrade twine 60 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 61 | 62 | .PHONY: help 63 | help: 64 | @echo "make start-opa" 65 | @echo " starts the opa server" 66 | @echo "make stop-opa" 67 | @echo " stops the opa server" 68 | @echo "make demo" 69 | @echo " runs the demo project" 70 | @echo "make test" 71 | @echo " run tests" 72 | @echo "make coverage" 73 | @echo " runs the tests and coverage" 74 | @echo "make lint" 75 | @echo " run lints of interest for the code" 76 | @echo "make install" 77 | @echo " install all requirements" 78 | @echo "make install-dev" 79 | @echo " install all requirements for development" 80 | @echo "make build" 81 | @echo " runs lints, tests and coverage" 82 | @echo "semver-test" 83 | @echo " Checks whether the SEMVER update can be done" 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask-OPA 2 | ========= 3 | [![Build Status](https://travis-ci.com/EliuX/flask-opa.svg?branch=master)](https://travis-ci.com/EliuX/flask-opa) 4 | [![codecov](https://codecov.io/gh/EliuX/flask-opa/branch/master/graph/badge.svg)](https://codecov.io/gh/EliuX/flask-opa) 5 | [![PyPI Version](http://img.shields.io/pypi/v/Flask-OPA.svg)](https://pypi.python.org/pypi/Flask-OPA) 6 | 7 | Simple to use [Flask](http://flask.pocoo.org) extension that lets you secure your projects with 8 | [Open Policy Agent](https://www.openpolicyagent.org). It allows 9 | * HTTP API Authorization 10 | * Policy Enforcement Point (AOP using decorators on methods) 11 | 12 | ## Quick start 13 | 14 | Its recommended for you to try out the app in the package `examples`. Thanks to the `Makefile` you can run the demo 15 | project with the following command 16 | 17 | ```bash 18 | make demo 19 | ``` 20 | 21 | ### How it works? 22 | 23 | For a better understanding of what `make demo` does and how, you should set up `flask_opa` in your project. Follow the 24 | next steps: 25 | 26 | 1. Run OPA in server mode 27 | 28 | * Check the [latest OPA release](https://github.com/open-policy-agent/opa/releases) and download it. 29 | * Put the binary file in the path of your system 30 | * Allow its execution with something like `chmod 755 ./opa` 31 | * Run opa in server mode with the sample policies 32 | 33 | ```bash 34 | opa run -s -w examples 35 | ``` 36 | 37 | - `-s` is to run it in server mode instead of opening the REPL 38 | - `-w` is for watching the changes of the data/policy files 39 | 40 | 1. Specify the configuration variables 41 | 42 | * `OPA_URL` url accessible in your running OPA server, used to evaluate your input. It includes the path of the 43 | policy, e.g. `http://localhost:8181/v1/data/examples/allow`. 44 | 45 | * `OPA_SECURED` boolean to specify if OPA will be enabled to your application. 46 | 47 | See more at the [rest api reference](https://www.openpolicyagent.org/docs/rest-api.html) 48 | 49 | 1. Bind the OPA class to your Flask application 50 | 51 | It is easy to bind the Flask-OPA library to your application. Just follow the following steps: 52 | 53 | 1. Create the OPA instance 54 | 55 | ```python 56 | app = Flask(__name__) 57 | app.config.from_pyfile('app.cfg') 58 | opa = OPA(app, parse_input) 59 | ``` 60 | 61 | Let's see the parameters that we passed to the OPA class: 62 | 63 | - `parse_input` (Required) contains a method that returns the input data json to be evaluated by the policy, e.g.: 64 | 65 | ```json 66 | { 67 | "input": { 68 | "method": "GET", 69 | "path": ["data", "jon"], 70 | "user": "paul" 71 | } 72 | } 73 | ``` 74 | 75 | - `url` (Optional) to use an specific url instead of the `OPA_URL` optionally specified in the app configuration. 76 | - `allow_function` (Optional) predicate that determinate if the response from OPA allows (True) or denies (False) the request 77 | 78 | If you want enforce the OPA security in your application you can create the OPA instance like this: 79 | 80 | ```python 81 | opa = OPA.secure(app, parse_input, url="http://localhost:8181/v1/data/package_name/allow") 82 | ``` 83 | 84 | or 85 | 86 | ```python 87 | opa = OPA(app, parse_input, url="http://localhost:8181/v1/data/package_name/allow").secured() 88 | ``` 89 | 90 | otherwise, OPA will enforce your security only if ``OPA_SECURED`` is `True`. 91 | 92 | Specify the logging level to `DEBUG` if you want to get access to Flask-OPA logs of its operations using 93 | 94 | ```python 95 | app.logger.setLevel(logging.DEBUG) 96 | ``` 97 | 98 | 1. Run your Flask application. 99 | 100 | ## Policy Enforcement point 101 | One of the features this module provides is [Policy Enforcement Point][PEP], which basically allows you to ensure 102 | policies at any method of your application. 103 | For practical purposes, lets imagine a sample method that is in charge of logging content related to some actions done by 104 | users. In this case we must create a different input functions that provide useful information for certain policies that 105 | will decide if a log should be sent or not to a remote server. Let's suppose that such logging method is something like: 106 | 107 | ```python 108 | def log_remotely(content): 109 | # Imagine a code to log this remotely 110 | app.logger.info("Logged remotely: %s", content) 111 | ``` 112 | 113 | Let's create a [PEP][PEP] decorator using our `OPA` instance as a function (callable mode) that will intercept every 114 | call to `log_remotely`. The parameters are pretty much the same as those used to secure the application. The resulting 115 | instance will decorate our function of interest: 116 | 117 | ```python 118 | def validate_logging_input_function(*arg, **kwargs): 119 | return { 120 | "input": { 121 | "user": request.headers.get("Authorization", ""), 122 | "content": arg[0] 123 | } 124 | } 125 | 126 | secure_logging = app.opa("Logging PEP", app.config["OPA_URL_LOGGING"], validate_logging_input_function) 127 | 128 | @secure_logging 129 | def log_remotely(content): 130 | # Imagine a code to log content remotely 131 | app.logger.info("Logged remotely: %s", content) 132 | ``` 133 | 134 | As you might have noticed, the only new thing we truly require for adding the [PEP][PEP] is a new input function. This 135 | function can provide a more versatile input than the one used by the `OPA` instance created for the whole app: in our 136 | example it provides data related to the user request and data provided by the parameters of the decorated function as 137 | well. 138 | 139 | Read the [examples README](examples/README.md) for more detailed information about how to run a demo. 140 | 141 | ## Error handling 142 | All errors related to OPA extend from `OPAException`. They will always be thrown unless the app variable 143 | `OPA_DENY_ON_FAIL` or `app.opa.deny_on_opa_fail` is set to `False`. 144 | 145 | ### Types of `OPAException` errors 146 | * `AccessDeniedException`: When the `allow_function` returns `False`, indicating that a policy denies the access. 147 | * `OPAServerUnavailableException`: When it cannot connect to the OPA Server. 148 | * `OPAUnexpectedException`: When the response of the OPA server is not `OK`, i.e. the status code is not `200`. 149 | 150 | ### Handling OPA Exceptions 151 | 152 | With the `errorhandler` decorator of the Flask app, you can easily catch any of these errors, e.g.: 153 | 154 | ```python 155 | @app.errorhandler(OPAException) 156 | def handle_opa_exception(e): 157 | return json.dumps({"message": str(e)}), 403 158 | ``` 159 | 160 | or particular ones: 161 | 162 | ```python 163 | @app.errorhandler(OPAServerUnavailableException) 164 | def handle_opa_exception_conn(e): 165 | app.logger.debug("Issue connecting to the OPA server: %s", e) 166 | return "Authorization cannot be enforced", 403 167 | ``` 168 | 169 | ## Makefile 170 | 171 | The Makefile contains multiple useful actions you might need. Check them with 172 | 173 | ```bash 174 | make help 175 | ``` 176 | 177 | ## Author 178 | 179 | Eliecer Hernandez Garbey 180 | 181 | ### Links 182 | 183 | - Main website: [EliuX Overflow](http://eliux.github.io) 184 | - Twitter: [@eliux_black](https://twitter.com/eliux_black) 185 | - LinkedIn: [eliecer-hernández-garbey-16172686](https://www.linkedin.com/in/eliecer-hern%C3%A1ndez-garbey-16172686/) 186 | - StackOverflow: [EliuX](https://stackoverflow.com/users/3233398/eliux) 187 | 188 | ## License 189 | 190 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 191 | 192 | 193 | [PEP]: https://tools.ietf.org/html/rfc2904#section-4.4 194 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-tactile -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | How to demo 2 | ============ 3 | 4 | ## Run OPA in server mode 5 | 6 | 1. Check the [latest OPA release](https://github.com/open-policy-agent/opa/releases) and download it. 7 | 2. Put the binary file in the `PATH` of your system 8 | 3. Allow its execution with something like 9 | ```bash 10 | chmod 755 ~/opa 11 | ``` 12 | 3. Run opa in server mode with the sample policies: 13 | 14 | ```bash 15 | cd examples 16 | opa run -s data.json app.rego 17 | ``` 18 | 19 | In this folder (examples) you will find a simple ``app.py`` file that contains endpoints to test how you can see some 20 | data using OPA. For commodity purposes the user id will be passed in the request using the header *Authorization*. 21 | 22 | > Run the app.py file with your IDE. It will run in http://localhost:5000 23 | 24 | 25 | ## Try out some http requests 26 | 27 | - Check the home page 28 | curl http://localhost:5000 29 | 30 | - Check list of users using a non admin user: Denied 31 | ```bash 32 | curl -X GET \ 33 | http://localhost:5000/list \ 34 | -H 'Authorization: paul' \ 35 | -H 'Content-Type: application/json' 36 | ``` 37 | 38 | - Check list of users using an admin user: Granted 39 | ```bash 40 | curl -X GET \ 41 | http://localhost:5000/list \ 42 | -H 'Authorization: eliux' \ 43 | -H 'Content-Type: application/json' 44 | ``` 45 | 46 | - Add a new user (steve) data using an admin user: Granted 47 | ```bash 48 | curl -X POST \ 49 | http://localhost:5000/data/steve \ 50 | -H 'Authorization: steve' \ 51 | -H 'Content-Type: application/json' \ 52 | -d '{ 53 | "fullname": "Steve Perez", 54 | "city": "New York", 55 | "salary": 3400, 56 | "biography": "Studied in harvard. Leaves in Miami, Florida" 57 | }' 58 | ``` 59 | 60 | - Get new user's data using another non admin user: Denied 61 | ```bash 62 | curl -X GET \ 63 | http://localhost:5000/data/steve \ 64 | -H 'Authorization: paul' \ 65 | -H 'Content-Type: application/json' 66 | ``` 67 | 68 | - Get new user's data using an admin user: Granted 69 | ```bash 70 | curl -X GET \ 71 | http://localhost:5000/data/steve \ 72 | -H 'Authorization: eliux' \ 73 | -H 'Content-Type: application/json' 74 | ``` 75 | 76 | - Get new user's data using same user, yet not a admin one: Granted 77 | ```bash 78 | curl -X GET \ 79 | http://localhost:5000/data/steve \ 80 | -H 'Authorization: steve' \ 81 | -H 'Content-Type: application/json' 82 | ``` 83 | 84 | - Try to delete a user using the same user but not an admin one: Denied 85 | ```bash 86 | curl -X DELETE \ 87 | http://localhost:5000/data/steve \ 88 | -H 'Authorization: steve' \ 89 | -H 'Content-Type: application/json' 90 | ``` 91 | 92 | - Try to delete a user using an admin user: Granted 93 | ```bash 94 | curl -X DELETE \ 95 | http://localhost:5000/data/steve \ 96 | -H 'Authorization: eliux' \ 97 | -H 'Content-Type: application/json' 98 | ``` 99 | 100 | ## Try Policy Enforcement Point on functions 101 | When you try to enforce policies beyond the endpoint layer you may not want to evaluate policies just depending on the data 102 | related to the user request, but probably you need to be aware of the parameters of a function. E.g. 103 | 104 | 105 | ### Problem 106 | 1. We want to create a function for logging data in a remote server. For this purpose we will create a log dedicated 107 | function called `log_remotely`, which will be used in multiple endpoints. 108 | 2. The log function will be allowed to log only info generated by admin users or if the content to be logged names any 109 | admin user. 110 | 111 | ### Solution 112 | 1. As the logging will be used in difference execution points of our app, the input of the corresponding policies should 113 | be independent of the request. In such cases its better to use a [Policy Enforcement Point][PEP] to enforce specialized 114 | policies for this particular scenario of security. 115 | 2. A new OPA package called `logging` will be created to enforce this particular set of policies. 116 | 117 | In this example project, logging a message will be allowed if: 118 | 119 | * The requesting user is administrator 120 | or 121 | * The logged content contains information about an administrator 122 | 123 | You can see the implementation of the [PEP][PEP] in Python in [utils.py](utils.py) and the policies in [logging.rego](logging.rego). 124 | 125 | Follow the following steps to demo this functionality. 126 | 127 | 1. Create a valid non admin user 128 | 129 | ```bash 130 | curl -X POST \ 131 | http://localhost:5000/data/steve \ 132 | -H 'Authorization: eliux' \ 133 | -H 'Content-Type: application/json' \ 134 | -d '{ 135 | "fullname": "Steve Perez", 136 | "city": "New York", 137 | "salary": 3400, 138 | "biography": "Studied in harvard. Leaves in Miami, Florida" 139 | }' 140 | ``` 141 | As the user executing the query is administrator you will see a log that says: 142 | 143 | ``` 144 | Updated user steve with data {...} 145 | ``` 146 | Which means that when the function `utils.log_remotely` the `OPA` returned `true` for the given input and the logging 147 | routing was executed successfully. 148 | 149 | 1. List the created user data with a non valid user 150 | 151 | ```bash 152 | curl -X GET \ 153 | http://localhost:5000/data/steve \ 154 | -H 'Authorization: steve' \ 155 | -H 'Content-Type: application/json' \ 156 | -H 'Postman-Token: dba83620-3117-4a57-b330-f1b0efd93c0b' \ 157 | -H 'cache-control: no-cache' 158 | ``` 159 | 160 | For the previous request the `OPA` policies of the application (app.rego) will allow steve to see its own data. 161 | Nonetheless, as steve is not an administrator and no admin user was referenced in the added content the previous 162 | request wont be logged. 163 | 164 | 1. Lets try the previous request but this time with an admin user. 165 | 166 | ```bash 167 | curl -X GET \ 168 | http://localhost:5000/data/steve \ 169 | -H 'Authorization: eliux' \ 170 | -H 'Content-Type: application/json' \ 171 | -H 'Postman-Token: dba83620-3117-4a57-b330-f1b0efd93c0b' \ 172 | -H 'cache-control: no-cache' 173 | ``` 174 | 175 | You should get something like 176 | 177 | ``` 178 | Logged remotely: Queried user steve 179 | ``` 180 | 181 | Check the [logging_test.rego](logging_test.rego) to check other valid and invalid queries. 182 | 183 | [PEP]: https://tools.ietf.org/html/rfc2904#section-4.4 184 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliuX/flask-opa/a85ff954382addc57c633fe9cdc49b184b76a13b/examples/__init__.py -------------------------------------------------------------------------------- /examples/app.cfg: -------------------------------------------------------------------------------- 1 | OPA_URL='http://localhost:8181/v1/data/examples/allow' 2 | OPA_URL_LOGGING='http://localhost:8181/v1/data/logging/allow' 3 | OPA_SECURED=False 4 | -------------------------------------------------------------------------------- /examples/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | OPA is expected to be running on default port 8181 4 | """ 5 | 6 | import json 7 | import logging 8 | 9 | from flask import Flask, request 10 | 11 | from flask_opa import OPA, OPAException 12 | 13 | 14 | def parse_input(): 15 | return { 16 | "input": { 17 | "method": request.method, 18 | "path": request.path.rstrip('/').strip().split("/")[1:], 19 | "user": request.headers.get("Authorization", ""), 20 | } 21 | } 22 | 23 | 24 | app = Flask(__name__) 25 | app.config.from_pyfile('app.cfg') 26 | 27 | app.opa = OPA(app, input_function=parse_input).secured() 28 | app.logger.setLevel(logging.DEBUG) 29 | 30 | import examples.utils as utils 31 | 32 | data = { 33 | 'eliux': { 34 | "fullname": "Eliecer Hernandez", 35 | "country": "Cuba", 36 | "age": 32, 37 | "ssn": "000-12-77632", 38 | "biography": "Was born in 1985 into a humble family..." 39 | } 40 | } 41 | 42 | 43 | @app.route("/") 44 | def welcome_page(): 45 | return "Hello Flask-OPA user! Lets see some data. " \ 46 | "Follow the instructions in the README of examples." 47 | 48 | 49 | @app.route("/list", methods=['GET']) 50 | def available_persons(): 51 | utils.log_remotely("All users listed") 52 | return json.dumps(list(data.keys())) 53 | 54 | 55 | @app.route("/data/", methods=['GET']) 56 | def show_data_of(who): 57 | if who in data: 58 | utils.log_remotely("Queried user %s" % who) 59 | return json.dumps(data[who]) 60 | else: 61 | return json.dumps({ 62 | "message": "%s was not found in our system" % who 63 | }), 404 64 | 65 | 66 | @app.route("/data/", methods=['POST']) 67 | def set_data_of(who): 68 | data[who] = json.loads(request.data) 69 | utils.log_remotely("Updated user %s with data {%s}" % (who, request.data)) 70 | return json.dumps(data[who]) 71 | 72 | 73 | @app.route("/data/", methods=['DELETE']) 74 | def delete(who): 75 | if who not in data: 76 | return json.dumps({ 77 | "message": "%s was not found in our system" % who 78 | }), 404 79 | del data[who] 80 | utils.log_remotely("Deleted user %s" % who) 81 | return json.dumps(None), 204 82 | 83 | 84 | @app.errorhandler(OPAException) 85 | def handle_opa_exception(e): 86 | return json.dumps({"message": str(e)}), 403 87 | 88 | 89 | if __name__ == '__main__': 90 | app.run(debug=True) 91 | -------------------------------------------------------------------------------- /examples/app.rego: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | import data.administrators 4 | 5 | default allow = false 6 | 7 | allow { 8 | input.method = "GET" 9 | input.path = [] 10 | } 11 | 12 | allow { 13 | input.method = "GET" 14 | input.path = ["list"] 15 | is_administrator 16 | } 17 | 18 | allow { 19 | input.method = "GET" 20 | its_own_data 21 | } 22 | 23 | allow { 24 | input.method = "GET" 25 | input.path = ["data", _] 26 | is_administrator 27 | } 28 | 29 | allow { 30 | input.method = "POST" 31 | its_own_data 32 | } 33 | 34 | allow { 35 | input.method = "POST" 36 | input.path = ["data", _] 37 | is_administrator 38 | } 39 | 40 | allow { 41 | input.method = "DELETE" 42 | input.path = ["data", _] 43 | is_administrator 44 | } 45 | 46 | pii = ["ssn"] { 47 | not its_own_data 48 | not is_administrator 49 | } 50 | 51 | its_own_data { 52 | input.path = ["data", user_id] 53 | input.user = user_id 54 | } 55 | 56 | is_administrator { 57 | input.user = administrators[_] 58 | } 59 | -------------------------------------------------------------------------------- /examples/app_test.rego: -------------------------------------------------------------------------------- 1 | package examples 2 | 3 | test_by_default_not_allowed { 4 | not allow 5 | } 6 | 7 | test_home_allowed { 8 | allow with input as {"method":"GET","path":[]} 9 | allow with input as {"method":"GET","path":[],"user":"invaliduser"} 10 | } 11 | 12 | test_get_list__using_non_admin_user_denied { 13 | not allow with input as {"method":"GET","path":["list"]} 14 | not allow with input as {"method":"GET","path":["list"], "user":""} 15 | not allow with input as {"method":"GET","path":["list"], "user":"anyone"} 16 | } 17 | 18 | test_get_list__using_admin_user_allowed { 19 | allow with input as {"method":"GET","path":["list"], "user":"eliux"} 20 | allow with input as {"method":"GET","path":["list"], "user":"jon"} 21 | } 22 | 23 | test_get_data__using_non_admin_user_denied { 24 | not allow with input as {"method":"GET","path":["data"]} 25 | not allow with input as {"method":"GET","path":["data","anyone"], "user":""} 26 | not allow with input as {"method":"GET","path":["data","eliux"], "user":"anyone"} 27 | } 28 | 29 | test_get_data__using_admin_user_allowed { 30 | allow with input as {"method":"GET","path":["data","anyone"], "user":"eliux"} 31 | allow with input as {"method":"GET","path":["data","anyone"], "user":"jon"} 32 | } 33 | 34 | test_get_its_own_data_allowed { 35 | allow with input as {"method":"GET","path":["data","eliux"], "user":"eliux"} 36 | allow with input as {"method":"GET","path":["data","anyone"], "user":"anyone"} 37 | } 38 | 39 | test_post_user_data_using_non_admin_user_denied { 40 | not allow with input as {"method":"POST","path":["data","eliux"], "user":""} 41 | not allow with input as {"method":"POST","path":["data","jon"], "user":"anyone"} 42 | } 43 | 44 | test_post_user_data_using_admin_user_allowed { 45 | allow with input as {"method":"POST","path":["data","anyone"], "user":"eliux"} 46 | allow with input as {"method":"POST","path":["data","eliux"], "user":"jon"} 47 | } 48 | 49 | test_post_its_own_data_allowed { 50 | allow with input as {"method":"POST","path":["data","eliux"], "user":"eliux"} 51 | allow with input as {"method":"POST","path":["data","anyone"], "user":"anyone"} 52 | } 53 | 54 | test_delete_using_non_admin_user_denied { 55 | not allow with input as {"method":"DELETE","path":["data","anyone"], "user":""} 56 | not allow with input as {"method":"DELETE","path":["data","eliux"], "user":"anyone"} 57 | } 58 | 59 | test_delete_using_admin_user_allowed { 60 | allow with input as {"method":"DELETE","path":["data","anyone"], "user":"eliux"} 61 | allow with input as {"method":"DELETE","path":["data","eliux"], "user":"jon"} 62 | } 63 | 64 | test_pii_non_admin_user_get_another_user_user { 65 | pii = ["ssn"] with input as {"method":"GET","path":["data","user1"], "user":"user2"} 66 | } -------------------------------------------------------------------------------- /examples/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "administrators" : ["eliux", "jon"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/logging.rego: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | 4 | import data.administrators 5 | 6 | default allow = false 7 | 8 | 9 | allow { 10 | input.user = administrators[_] 11 | } 12 | 13 | allow { 14 | contains(input.content, administrators[_]) 15 | } -------------------------------------------------------------------------------- /examples/logging_test.rego: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | test_default_denied { 4 | not allow 5 | } 6 | 7 | test_admin_user_allowed { 8 | allow with input as {"user": "eliux"} 9 | allow with input as {"user": "jon", "content": "..."} 10 | } 11 | 12 | test_non_admin_users_denied { 13 | not allow with input as {"user": ""} 14 | not allow with input as {"user": "george"} 15 | } 16 | 17 | test_content_related_to_admin_allowed { 18 | allow with input as {"user": "anyone", "content": "requested help to eliux"} 19 | allow with input as {"user": "anyone", "content": "requested help to jon snow"} 20 | } 21 | 22 | test_content_non_related_to_admin_denied { 23 | not allow with input as {"user": "anyone"} 24 | not allow with input as {"user": "anyone", "content": ""} 25 | not allow with input as {"user": "anyone", "content": "lorem ipsum dolor"} 26 | } -------------------------------------------------------------------------------- /examples/utils.py: -------------------------------------------------------------------------------- 1 | from flask.globals import request 2 | 3 | from examples.app import app 4 | 5 | 6 | def validate_logging_input_function(*arg, **kwargs): 7 | return { 8 | "input": { 9 | "user": request.headers.get("Authorization", ""), 10 | "content": arg[0] 11 | } 12 | } 13 | 14 | 15 | secure_logging = app.opa("Logging PEP", app.config["OPA_URL_LOGGING"], validate_logging_input_function) 16 | 17 | 18 | @secure_logging 19 | def log_remotely(content): 20 | # Imagine a code to log this remotely 21 | app.logger.info("Logged remotely: %s", content) 22 | -------------------------------------------------------------------------------- /flask_opa.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask Extension for OPA 3 | """ 4 | import requests 5 | from flask.app import Flask 6 | 7 | __version__ = "1.0.0" 8 | 9 | 10 | class OPAException(Exception): 11 | """Exception evaluating a request in OPA""" 12 | 13 | def __init__(self, message): 14 | super().__init__(message) 15 | 16 | 17 | class OPAUnexpectedException(OPAException): 18 | """Unexpected error evaluating the request in OPA""" 19 | 20 | def __init__(self, message='Unexpected error'): 21 | super().__init__(message) 22 | 23 | 24 | class AccessDeniedException(OPAException): 25 | """OPA Denied the request""" 26 | 27 | def __init__(self, message='Denied'): 28 | super().__init__(message) 29 | 30 | 31 | class OPAServerUnavailableException(OPAException): 32 | """When it cannot connect to the OPA Server""" 33 | 34 | def __init__(self, message='OPA Server unavailable'): 35 | super().__init__(message) 36 | 37 | 38 | class OPA(object): 39 | def __init__(self, 40 | app: Flask, 41 | input_function, 42 | url: str = None, 43 | allow_function=None, 44 | wait_time: int = 20000): 45 | super(OPA, self).__init__() 46 | self._app = app 47 | self._pep = {} 48 | self._input_function = input_function 49 | self._allow_function = allow_function or self.default_allow_function 50 | self._deny_on_opa_fail = app.config.get('OPA_DENY_ON_FAIL', True) 51 | self._url = url or app.config.get('OPA_URL') 52 | self._wait_time = wait_time or app.config.get('OPA_WAIT_TIME') 53 | if self._app.config.get('OPA_SECURED', False): 54 | self.secured() 55 | 56 | @staticmethod 57 | def secure(*args, **kwargs): 58 | return OPA(*args, **kwargs).secured() 59 | 60 | def secured(self, 61 | url=None, 62 | input_function=None, 63 | allow_function=None): 64 | """Secure app""" 65 | if self.check_authorization not in self._app.before_request_funcs: 66 | self._url = url or self._url 67 | self._allow_function = allow_function or self._allow_function 68 | self._input_function = input_function or self._input_function 69 | if self._url and self._input_function and self._allow_function: 70 | self._app.before_request(self.check_authorization) 71 | else: 72 | raise ValueError("Invalid OPA configuration") 73 | return self 74 | 75 | def check_authorization(self): 76 | input = self.input 77 | url = self.url 78 | try: 79 | response = self.query_opa(url, input) 80 | if response is not None: 81 | self.check_opa_response(response) 82 | except OPAException as e: 83 | if self.deny_on_opa_fail: 84 | raise e 85 | 86 | def query_opa(self, url, input): 87 | self._app.logger.debug("%s query: %s. content: %s", 88 | self.app, url, input) 89 | try: 90 | return requests.post(url, json=input, timeout=self.wait_time) 91 | except requests.exceptions.ConnectionError as e: 92 | if self.deny_on_opa_fail: 93 | raise OPAServerUnavailableException(str(e)) 94 | 95 | def check_opa_response(self, response): 96 | if response.status_code != 200: 97 | opa_error = "OPA status code: {}. content: {}".format( 98 | response.status_code, str(response) 99 | ) 100 | self._app.logger.error(opa_error) 101 | raise OPAUnexpectedException(opa_error) 102 | resp_json = response.json() 103 | self._app.logger.debug(" => %s", resp_json) 104 | if not self.allow_function(resp_json): 105 | raise AccessDeniedException() 106 | return resp_json 107 | 108 | def __call__(self, name: str, url: str, 109 | input_function=None, 110 | allow_function=None): 111 | """Creates a PEP""" 112 | return PEP(self, name, url, input_function, allow_function) 113 | 114 | @property 115 | def pep(self): 116 | return self._pep 117 | 118 | @property 119 | def url(self): 120 | return self._url 121 | 122 | @url.setter 123 | def url(self, value): 124 | self._url = value 125 | 126 | @property 127 | def deny_on_opa_fail(self): 128 | return self._deny_on_opa_fail 129 | 130 | @deny_on_opa_fail.setter 131 | def deny_on_opa_fail(self, value): 132 | self._deny_on_opa_fail = value 133 | 134 | @property 135 | def input(self): 136 | return self.input_function() 137 | 138 | @property 139 | def input_function(self): 140 | return self._input_function 141 | 142 | @property 143 | def allow_function(self): 144 | return self._allow_function 145 | 146 | @property 147 | def app(self): 148 | return self._app 149 | 150 | @property 151 | def wait_time(self): 152 | return self._wait_time 153 | 154 | @wait_time.setter 155 | def wait_time(self, value): 156 | self._wait_time = value 157 | 158 | @classmethod 159 | def default_allow_function(cls, response_json): 160 | return response_json.get('result', False) 161 | 162 | 163 | class PEP(OPA): 164 | """Class to handle Policy Enforcement Points""" 165 | 166 | def __init__(self, 167 | opa: OPA, 168 | name: str, 169 | url: str, 170 | input_function=None, 171 | allow_function=None, 172 | deny_on_opa_fail: bool = False): 173 | super(OPA, self).__init__() 174 | self._app = opa.app 175 | opa.pep[name] = self 176 | self._url = url 177 | self._input_function = input_function or opa.input_function 178 | self._allow_function = allow_function or opa.allow_function 179 | self._deny_on_opa_fail = deny_on_opa_fail or False 180 | self._wait_time = opa.wait_time 181 | self._name = name or "PEP" 182 | if not (self._app and self._url and 183 | self._input_function and self._allow_function): 184 | raise ValueError("Invalid Police Enforcement Point configuration") 185 | 186 | def check_authorization(self, *args, **kwargs): 187 | _input = self.input(*args, **kwargs) 188 | response = self.query_opa(self.url, _input) 189 | if response is not None: 190 | self.check_opa_response(response) 191 | 192 | def __call__(self, f): 193 | def secure_function(*args, **kwargs): 194 | try: 195 | self.check_authorization(*args, **kwargs) 196 | return f(*args, **kwargs) 197 | except OPAException as e: 198 | if self.deny_on_opa_fail: 199 | raise e 200 | 201 | return secure_function 202 | 203 | def input(self, *args, **kwargs): 204 | return self._input_function(*args, **kwargs) 205 | 206 | def __str__(self): 207 | return "<{}>".format(self._name) 208 | -------------------------------------------------------------------------------- /hq: -------------------------------------------------------------------------------- 1 | commit 31dd0d5f86859bf7eda69b89d9054bcdfe54edfd (HEAD -> feature/setup-semver-v1) 2 | Author: EliuX 3 | Date: Mon Mar 15 22:19:49 2021 -0400 4 | 5 | ci: Add semantic-release for SEMVER 6 | 7 | commit 3d43fd06dbf8be72f260ed53106e56e363e9a0a5 8 | Author: EliuX 9 | Date: Mon Mar 15 22:15:01 2021 -0400 10 | 11 | docs: Update README 12 | 13 | commit b0a75616620ed6ca84ac5ac4cf24ffa43582f24f (origin/master, master) 14 | Author: EliuX 15 | Date: Sun Sep 27 02:01:59 2020 -0500 16 | 17 | Enable skip_existing in Travis 18 | 19 | commit 864c3392944436e2c98c71399ab1a502c54e8892 20 | Author: EliuX 21 | Date: Sun Sep 27 01:38:40 2020 -0500 22 | 23 | Close #8 Document about OPAException errors 24 | 25 | commit a1b0b501487f7cdc30dd1ea19f8fcc765625161f 26 | Author: EliuX 27 | Date: Sun Sep 27 01:02:40 2020 -0500 28 | 29 | Fix tests 30 | 31 | commit 37fc11a3f8e0f82a055ecfcbeee40c9978941634 32 | Author: EliuX 33 | Date: Sat Sep 26 23:30:19 2020 -0500 34 | 35 | Close #9 Skip redundant log 36 | 37 | commit 1c581b33ae70c98cbf27a588ccad41a02443a75b 38 | Author: EliuX 39 | Date: Sat Sep 26 23:05:31 2020 -0500 40 | 41 | Update copyright year and .gitignore 42 | 43 | commit f4073f2172cfb65148f53d069320444ee2a4759b (origin/feature/opa_wait_time, feature/opa_wait_time, feature/improve-code-coverage-to-100percent) 44 | Author: EliuX 45 | Date: Sun Nov 4 23:20:59 2018 -0500 46 | 47 | Fix failing test 48 | 49 | commit fb993cb726d497af2c04c342648cc69a49747069 50 | Author: EliuX 51 | Date: Sun Nov 4 23:12:09 2018 -0500 52 | 53 | Upgrade to v0.6 54 | 55 | commit 28ac1dda039932157d340c4aec05c601a3a2596c 56 | Merge: c251b39 095d514 57 | Author: Eliecer Hernandez 58 | Date: Sun Nov 4 23:11:18 2018 -0500 59 | 60 | Merge branch 'master' into feature/opa_wait_time 61 | 62 | commit c251b399e1f21996ae42f6e5720c29d2ee9d4285 63 | Author: EliuX 64 | Date: Sun Nov 4 22:57:15 2018 -0500 65 | 66 | Add support for wait_time #2 67 | 68 | commit 095d5148ff0e292e16822df419ed423492254518 69 | Author: Eliecer Hernandez 70 | Date: Sun Nov 4 12:29:07 2018 -0500 71 | 72 | Set theme jekyll-theme-tactile 73 | 74 | commit 5b0042beebed2024a80a04738b3e9754221460f8 75 | Author: Eliecer Hernandez 76 | Date: Sun Nov 4 11:58:09 2018 -0500 77 | 78 | Set theme jekyll-theme-cayman 79 | 80 | commit 2a88bc2376ae9b5b5065abb8bbb849a118132757 81 | Author: Eliecer Hernandez 82 | Date: Sun Nov 4 11:39:28 2018 -0500 83 | 84 | Set theme jekyll-theme-minimal 85 | 86 | commit 7eedb8a2b47d50f07f85e766579b5d441a667b62 87 | Author: Eliecer Hernandez 88 | Date: Sun Nov 4 00:22:23 2018 -0500 89 | 90 | Add makefile (#4) 91 | 92 | * Allow skipping errors on connections to OPA 93 | * Add Makefile compliant with OPA contrib standards 94 | * Reference Makefile help command in README for assistance 95 | 96 | commit 55aaad9f8624e07a55f70c91cacd11d4b6e700a3 (origin/gh-pages, origin/add-makefile, gh-pages, add-makefile) 97 | Author: EliuX 98 | Date: Sat Nov 3 18:16:24 2018 -0500 99 | 100 | Reference Makefile help command in README for assistance 101 | 102 | commit 30b23d206cbe1f7bb5b3bdcfeae2c928261624ea 103 | Author: EliuX 104 | Date: Sat Nov 3 17:54:49 2018 -0500 105 | 106 | Upgrade Makefile to be compliant with OPA contrib standards 107 | 108 | commit 963d7ac41d6943196ba13791019874c6620ce0d1 109 | Author: EliuX 110 | Date: Sat Nov 3 16:53:25 2018 -0500 111 | 112 | Add Makefile 113 | 114 | commit 59330e507a6c7d77db4aa031f66cb7a16388c5f0 115 | Author: EliuX 116 | Date: Sat Nov 3 16:25:35 2018 -0500 117 | 118 | Allow skipping errors on connections to OPA 119 | 120 | commit 8f714cb7ce6bfa1ce44d90f687c80444eed8b689 121 | Author: EliuX 122 | Date: Wed Oct 31 21:15:44 2018 -0500 123 | 124 | Release v0.4 125 | 126 | commit c83d44606356a3c53ce8078d12a5fbe4af488eaa 127 | Author: EliuX 128 | Date: Sat Oct 20 19:43:33 2018 -0500 129 | 130 | Full coverage 131 | 132 | commit 0df8a56a67f57b1000a669b370ad8c6a7ae7ca39 133 | Author: Eliecer Hernandez 134 | Date: Wed Oct 31 11:41:33 2018 -0500 135 | 136 | Add PEP support 137 | 138 | Add class PEP to module 139 | * Add and test new example policies for logging 140 | * Full coverage 141 | * Fix codebase, tests and documentation related to PEP 142 | * Upgrade to version 0.5-beta 143 | * Update READMEs 144 | 145 | commit 2b4150e1b90d2db1036a35b9ba45df9272c53769 146 | Author: EliuX 147 | Date: Tue Oct 16 21:50:22 2018 -0500 148 | 149 | Fix .travis.yml 150 | 151 | commit 716f09f5d84e75a81f7904c70d9a51f1ce182582 152 | Author: EliuX 153 | Date: Tue Oct 16 19:20:41 2018 -0500 154 | 155 | Update configuration to deploy in PyPi 156 | 157 | commit 15f437b4449f7b2b82dbe69307bef9c69cd502bd 158 | Author: EliuX 159 | Date: Tue Oct 16 01:34:08 2018 -0500 160 | 161 | Set README.rst as long description 162 | 163 | commit 8f96ab60e973699b6ad0a1190813b646f7e2f1fa 164 | Author: EliuX 165 | Date: Tue Oct 16 01:16:00 2018 -0500 166 | 167 | Fix coverage in Travis CI config 168 | 169 | commit d9703287df7289458d0b63f3ef129d117d651e45 170 | Author: EliuX 171 | Date: Tue Oct 16 00:49:17 2018 -0500 172 | 173 | Add Travis CI support 174 | 175 | commit 4def147aa745466900b17c1f54c857a5af3cd033 176 | Author: EliuX 177 | Date: Tue Oct 16 00:34:39 2018 -0500 178 | 179 | Fix flake8 warnings 180 | 181 | commit dcedee0412886cba365b1664857960a90f41e621 182 | Author: EliuX 183 | Date: Tue Oct 16 00:17:42 2018 -0500 184 | 185 | Add Pytests and Coverage 186 | 187 | commit b7066b446653f7f4dd9db56561e2d54e9c4a8eae 188 | Author: EliuX 189 | Date: Mon Oct 15 21:31:45 2018 -0500 190 | 191 | Allow to enforce security by configuration 192 | 193 | commit 66868eddc72caa1499df58a6c6396b5105a2c5a8 194 | Author: EliuX 195 | Date: Mon Oct 15 01:52:42 2018 -0500 196 | 197 | Update README 198 | 199 | commit b30463ce5a86172f9fc590146b3ee0fc64791491 200 | Author: EliuX 201 | Date: Mon Oct 15 01:42:56 2018 -0500 202 | 203 | Add MIT License 204 | 205 | commit d3f078fc0af3d86fdcbc3f19d12ee085449e474b 206 | Author: EliuX 207 | Date: Mon Oct 15 01:38:00 2018 -0500 208 | 209 | Add example files and fix extension module 210 | 211 | commit 2338dc5e5481d44324ab8ea752d81181d0c02aec 212 | Author: EliuX 213 | Date: Sat Oct 13 22:28:49 2018 -0500 214 | 215 | Add OPA class and related exceptions 216 | 217 | commit 8a17e69fc4d48ec092c07de4b36c713a8da1d13d 218 | Author: EliuX 219 | Date: Sat Oct 13 19:44:36 2018 -0500 220 | 221 | First commit: Setup project structure 222 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | description-file = README.rst 6 | 7 | [aliases] 8 | test=pytest 9 | 10 | [tool:pytest] 11 | testpaths = tests 12 | 13 | [flake8] 14 | ignore = F811,W503,W504 15 | exclude = .git,__pycache__,venv,build,dist,examples 16 | max-line-length = 80 17 | 18 | [semantic_release] 19 | version_variable = flask_opa.py:__version__,setup.py:__version__ 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Flask-OPA 4 | ------------- 5 | 6 | Flask extension that lets you use Open Policy Agent (OPA) in your project 7 | as a client 8 | """ 9 | from setuptools import setup, find_packages 10 | 11 | __version__ = "1.0.0" 12 | 13 | 14 | def readme(): 15 | with open('README.md') as f: 16 | return f.read() 17 | 18 | 19 | setup( 20 | name='Flask-OPA', 21 | version=__version__, 22 | url='https://github.com/EliuX/Flask-OPA', 23 | license='MIT', 24 | author='Eliecer Hernandez Garbey', 25 | author_email='eliecerhdz@gmail.com', 26 | description='Flask extension to use OPA as a client', 27 | long_description=readme(), 28 | long_description_content_type="text/markdown", 29 | py_modules=['flask_opa'], 30 | zip_safe=False, 31 | install_requires=[ 32 | 'Flask', 33 | 'requests' 34 | ], 35 | classifiers=[ 36 | 'Environment :: Web Environment', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: MIT License', 39 | 'Operating System :: OS Independent', 40 | 'Programming Language :: Python', 41 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | 'Topic :: System :: Systems Administration :: Authentication/Directory', 44 | ], 45 | project_urls={ 46 | "Bug Tracker": "https://github.com/EliuX/Flask-OPA/issues", 47 | "Source Code": "https://github.com/EliuX/Flask-OPA", 48 | } 49 | ) 50 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | Check the code 2 | ================ 3 | 4 | ## Run the tests 5 | For running the tests with Py.test execute 6 | ```bash 7 | pytest -v 8 | ``` 9 | 10 | ## Check the coverage 11 | The intended code coverage is of about 85% 12 | ```bash 13 | coverage run -m pytest -v 14 | ``` 15 | 16 | ## Check if the code is PEP compliant 17 | ```bash 18 | flake8 --ignore=F811 flask_opa.py 19 | ``` 20 | Using ``--ignore=F811`` ignores unused static methods 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fixtures 3 | """ 4 | import json 5 | 6 | import pytest 7 | from flask.app import Flask, request 8 | 9 | from flask_opa import OPA, OPAException 10 | 11 | 12 | def parse_input(): 13 | return { 14 | "input": { 15 | "method": request.method, 16 | "path": request.path.strip().split("/")[1:], 17 | "user": request.headers.get("Authorization", ""), 18 | } 19 | } 20 | 21 | 22 | DATABASE_POLICIES_URL = 'http://localhost:8181/v1/data/examples/db/allow' 23 | 24 | 25 | @pytest.fixture 26 | def app(): 27 | """Import the test app""" 28 | app = Flask(__name__) 29 | app.config["OPA_SECURED"] = True 30 | app.config["OPA_URL"] = 'http://localhost:8181/v1/data/examples/allow' 31 | app.opa = OPA(app, input_function=parse_input).secured() 32 | init_app(app) 33 | return app 34 | 35 | 36 | @pytest.fixture 37 | def app_with_missing_url(): 38 | app = Flask(__name__) 39 | app.opa = OPA(app, input_function=parse_input) 40 | init_app(app) 41 | return app 42 | 43 | 44 | @pytest.fixture 45 | def app_secured_from_configuration(): 46 | app = Flask(__name__) 47 | app.config["OPA_SECURED"] = True 48 | app.config["OPA_URL"] = 'http://localhost:8181/v1/data/examples/allow' 49 | app.opa = OPA(app, input_function=parse_input) 50 | init_app(app) 51 | return app 52 | 53 | @pytest.fixture 54 | def app_using_pep(app): 55 | init_pep(app) 56 | return app 57 | 58 | def init_app(app): 59 | @app.route("/") 60 | def welcome_page(): 61 | return "Test Home page" 62 | 63 | @app.errorhandler(OPAException) 64 | def handle_opa_exception(e): 65 | return json.dumps({"message": str(e)}), 403 66 | 67 | 68 | def init_pep(app): 69 | def input_function_search_pep(*args, **kwargs): 70 | input = parse_input() 71 | input["text"] = kwargs.get("text") or args[0] 72 | return input 73 | 74 | secured_query = app.opa("Database PEP", 75 | DATABASE_POLICIES_URL, 76 | input_function_search_pep) 77 | 78 | @secured_query 79 | def query_data(text): 80 | return ["%s at the beginning" % text, 81 | "The word %s in the middle" % text, 82 | "In the end %s" % text] 83 | 84 | @app.route("/search") 85 | def search_page(): 86 | result = query_data(text=request.args.get('q')) 87 | return json.dumps({"result": result}), 200 88 | -------------------------------------------------------------------------------- /tests/test_opa_class.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import responses 4 | from flask import json 5 | from flask.app import Flask 6 | 7 | from flask_opa import AccessDeniedException, OPA, OPAUnexpectedException, OPAServerUnavailableException 8 | from tests.conftest import DATABASE_POLICIES_URL, parse_input, init_app 9 | 10 | 11 | @responses.activate 12 | def test_get_home_page_granted(app): 13 | opa_url = app.config.get('OPA_URL') 14 | responses.add(responses.POST, opa_url, 15 | json={'result': True}, status=200) 16 | 17 | response = app.test_client().get('/') 18 | 19 | assert 0 < len(response.data) 20 | assert 200 == response.status_code 21 | 22 | 23 | @responses.activate 24 | def test_opa_grant_access(app): 25 | opa_url = app.config.get('OPA_URL') 26 | responses.add(responses.POST, opa_url, json={'result': True}, status=200) 27 | 28 | response = app.test_client().get('/') 29 | 30 | assert 0 < len(response.data) 31 | assert 200 == response.status_code 32 | 33 | 34 | @responses.activate 35 | def test_opa_create_with_staticmethod_deny_access(): 36 | app = Flask(__name__) 37 | opa_url = 'http://localhost:8181/v1/data/dm/allow' 38 | app.opa = OPA.secure(app, input_function=parse_input, url=opa_url) 39 | init_app(app) 40 | 41 | responses.add(responses.POST, opa_url, json={'result': False}, status=200) 42 | 43 | response = app.test_client().get('/') 44 | 45 | assert 403 == response.status_code 46 | 47 | 48 | @responses.activate 49 | def test_opa_denies_access(app): 50 | opa_url = app.config.get('OPA_URL') 51 | responses.add(responses.POST, opa_url, json={'result': False}, status=200) 52 | 53 | response = app.test_client().post('/') 54 | 55 | assert 403 == response.status_code 56 | 57 | 58 | @pytest.mark.xfail(raises=ValueError) 59 | def test_app_with_missing_url(app_with_missing_url): 60 | pass 61 | 62 | 63 | @responses.activate 64 | @pytest.mark.xfail(raises=AccessDeniedException) 65 | def test_app_secured_from_configuration_raises_access_denied(app_secured_from_configuration): 66 | opa_url = app_secured_from_configuration.config.get('OPA_URL') 67 | responses.add(responses.POST, opa_url, json={'result': False}, status=200) 68 | 69 | app_secured_from_configuration.test_client().post('/') 70 | 71 | 72 | @responses.activate 73 | def test_app_secured_with_pep_allow_access(app_using_pep): 74 | responses.add(responses.POST, 75 | DATABASE_POLICIES_URL, 76 | json={'result': True}, 77 | status=200) 78 | opa_url = app_using_pep.config.get('OPA_URL') 79 | responses.add(responses.POST, opa_url, json={'result': True}, status=200) 80 | 81 | response = app_using_pep.test_client().get('/search?q=lorem') 82 | 83 | assert 0 < len(response.data) 84 | assert 200 == response.status_code 85 | 86 | 87 | @responses.activate 88 | def test_when_pep_fails_and_deny_on_opa_fail_is_false_then_result_not_returned(app_using_pep): 89 | app_using_pep.opa.pep["Database PEP"]._deny_on_opa_fail = False 90 | 91 | opa_url = app_using_pep.config.get('OPA_URL') 92 | responses.add(responses.POST, 93 | opa_url, 94 | json={'result': True}, 95 | status=200) 96 | 97 | responses.add(responses.POST, 98 | DATABASE_POLICIES_URL, 99 | json={'result': False}, 100 | status=200) 101 | 102 | response = app_using_pep.test_client().get('/search?q=lorem') 103 | 104 | assert 200 == response.status_code 105 | assert json.loads(response.data).get("result", "") is None 106 | 107 | 108 | @responses.activate 109 | def test_when_pep_fails_and_deny_on_opa_fail_is_true_then_return_403(app_using_pep): 110 | app_using_pep.opa.pep["Database PEP"]._deny_on_opa_fail = True 111 | 112 | opa_url = app_using_pep.config.get('OPA_URL') 113 | responses.add(responses.POST, 114 | opa_url, 115 | json={'result': True}, 116 | status=200) 117 | 118 | responses.add(responses.POST, 119 | DATABASE_POLICIES_URL, 120 | json={'result': False}, 121 | status=200) 122 | 123 | response = app_using_pep.test_client().get('/search?q=lorem') 124 | 125 | assert 403 == response.status_code 126 | 127 | 128 | def test_app_without_opa_input_function_raise_value_error(app): 129 | with pytest.raises(ValueError): 130 | app.config['OPA_SECURED'] = True 131 | app.config['OPA_URL'] = 'http://localhost:8181/v1/data/examples/allow' 132 | app.opa = OPA(app, input_function=None).secured() 133 | 134 | 135 | def test_app_with_pep_with_no_url_raise_value_error(app): 136 | with pytest.raises(ValueError): 137 | app.opa('Database PEP', '') 138 | 139 | 140 | @responses.activate 141 | def test_change_app_opa_url(app): 142 | app.opa.url = app.config.get('OPA_URL') 143 | responses.add(responses.POST, app.opa.url, 144 | json={'result': True}, status=200) 145 | 146 | response = app.test_client().get('/') 147 | 148 | assert 200 == response.status_code 149 | 150 | 151 | @responses.activate 152 | @pytest.mark.xfail(raises=OPAUnexpectedException) 153 | def test_app_retrieves_non_ok_value_should_raise_error(app): 154 | app.opa.url = app.config.get('OPA_URL') 155 | 156 | responses.add(responses.POST, app.opa.url, 157 | json={'result': True}, status=404) 158 | 159 | response = app.test_client().get('/') 160 | 161 | assert 403 == response.status_code 162 | 163 | 164 | @responses.activate 165 | def test_app_that_denies_on_fail_when_no_connection_then_deny_access(app): 166 | app.opa.deny_on_opa_fail = True 167 | app.opa.url = app.config.get('OPA_URL') 168 | 169 | error_when_opa_server_is_down = requests.exceptions.ConnectionError("OPA server down") 170 | responses.add(responses.POST, app.opa.url, 171 | body=error_when_opa_server_is_down) 172 | 173 | response = app.test_client().get('/') 174 | 175 | assert 'OPA server down' == json.loads(response.data).get("message", "") 176 | assert 403 == response.status_code 177 | 178 | 179 | def test_opa_with_pep_name(app_using_pep): 180 | pep = app_using_pep.opa.pep['Database PEP'] 181 | 182 | assert "Database PEP" in str(pep) 183 | 184 | 185 | def test_app_using_pep_that_doesnt_denies_on_fail_when_no_connection_then_grant_access(app_using_pep): 186 | app_using_pep.opa.deny_on_opa_fail = False 187 | app_using_pep.opa.pep["Database PEP"].deny_on_opa_fail = False 188 | 189 | response = app_using_pep.test_client().get('/') 190 | 191 | assert 200 == response.status_code 192 | --------------------------------------------------------------------------------