├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docker-compose.yml ├── pyproject.toml ├── setup.py ├── sql ├── create_tables.sql └── fill_tables.sql └── src └── opal_fetcher_postgres ├── __init__.py └── provider.py /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: check-toml 8 | - repo: https://github.com/psf/black 9 | rev: 23.1.0 10 | hooks: 11 | - id: black 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM permitio/opal-client:latest 2 | COPY --chown=opal . /app/ 3 | RUN cd /app && python setup.py install --user 4 | RUN pip install wait-for-it 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | # python packages (pypi) 6 | clean: 7 | rm -rf *.egg-info build/ dist/ 8 | 9 | publish: 10 | $(MAKE) clean 11 | python setup.py sdist bdist_wheel 12 | python -m twine upload dist/* 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | opal 3 |

4 |

5 | OPAL Fetcher for Postgres 6 |

7 | 8 | [Check out OPAL main repo here.](https://github.com/permitio/opal) 9 | 10 | ### What's in this repo? 11 | An OPAL [custom fetch provider](https://docs.opal.ac/tutorials/write_your_own_fetch_provider) to bring authorization state from [Postgres](https://www.postgresql.org/). 12 | 13 | This fetcher is both: 14 | - **A fully functional fetch-provider for Postgres:** can be used by OPAL to fetch data from Postgres DB. 15 | - **Serving as an example** how to write custom fetch providers for OPAL and how to publish them as pip packages. 16 | 17 | ### How to try this custom fetcher in one command? (Example docker-compose configuration) 18 | 19 | You can test this fetcher with the example docker compose file in this repository root. Clone this repo, `cd` into the cloned repo, and then run: 20 | ``` 21 | docker compose up 22 | ``` 23 | this docker compose configuration already correctly configures OPAL to load the Postgres Fetch Provider, and correctly configures `OPAL_DATA_CONFIG_SOURCES` to include an entry that uses this fetcher. 24 | 25 | ### ✏️ How to use this fetcher in your OPAL Setup 26 | 27 | #### 1) Build a custom opal-client `Dockerfile` 28 | 29 | The official docker image only contains the built-in fetch providers. You need to create your own `Dockerfile` (that is based on the official docker image), that includes this fetcher's pip package. 30 | 31 | Your `Dockerfile` should look like this: 32 | ``` 33 | FROM permitio/opal-client:latest 34 | RUN pip install --no-cache-dir --user opal-fetcher-postgres 35 | ``` 36 | 37 | #### 2) Build your custom opal-client container 38 | Say your special Dockerfile from step one is called `custom_client.Dockerfile`. 39 | 40 | You must build a customized OPAL container from this Dockerfile, like so: 41 | ``` 42 | docker build -t yourcompany/opal-client -f custom_client.Dockerfile . 43 | ``` 44 | 45 | #### 3) When running OPAL, set `OPAL_FETCH_PROVIDER_MODULES` 46 | Pass the following environment variable to the OPAL client docker container (comma-separated provider modules): 47 | ``` 48 | OPAL_FETCH_PROVIDER_MODULES=opal_common.fetcher.providers,opal_fetcher_postgres.provider 49 | ``` 50 | Notice that OPAL receives a list from where to search for fetch providers. 51 | The list in our case includes the built-in providers (`opal_common.fetcher.providers`) and our custom postgres provider. 52 | 53 | #### 4) Using the custom provider in your DataSourceEntry objects 54 | 55 | Your DataSourceEntry objects (either in `OPAL_DATA_CONFIG_SOURCES` or in dynamic updates sent via the OPAL publish API) can now include this fetcher's config. 56 | 57 | Example value of `OPAL_DATA_CONFIG_SOURCES` (formatted nicely, but in env var you should pack this to one-line and no-spaces): 58 | ```json 59 | { 60 | "config": { 61 | "entries": [ 62 | { 63 | "url": "postgresql://postgres@example_db:5432/postgres", 64 | "config": { 65 | "fetcher": "PostgresFetchProvider", 66 | "query": "SELECT * from city;", 67 | "connection_params": { 68 | "password": "postgres" 69 | } 70 | }, 71 | "topics": [ 72 | "policy_data" 73 | ], 74 | "dst_path": "cities" 75 | } 76 | ] 77 | } 78 | } 79 | ``` 80 | 81 | Notice how `config` is an instance of `PostgresFetcherConfig` (code is in `opal_fetcher_postgres/provider.py`). 82 | 83 | Values for this fetcher config: 84 | * The `url` is actually a postgres dsn. You can set the postgres password in the dsn itself if you want. 85 | * `connection_params` are optional, if you want to include certain overrides outside the dsn. 86 | * Your `config` must include the `fetcher` key to indicate to OPAL that you use a custom fetcher. 87 | * Your `config` must include the `query` key to indicate what query to run against postgres. 88 | 89 | ### 🚩 Possible User Issues 90 | While trying to send requests to a Postgres data source, you may encounter that the request fails. This can be caused by the format of the config entry URL for which the standard is: 91 | 92 | `postgresql://:@/` 93 | 94 | It might be most common that this request fails due to the password field being incorrectly parsed by the underlying library called `asyncpg`, which is one of the required libraries used within our OPAL custom data fetcher. 95 | 96 | In order to solve the issue, you need to change the data source config entry URL to the format shown below: 97 | 98 | `postgresql:///?user=&password=` 99 | 100 | ### 📖 About OPAL (Open Policy Administration Layer) 101 | [OPAL](https://github.com/permitio/opal) is an administration layer for Open Policy Agent (OPA), detecting changes to both policy and policy data in realtime and pushing live updates to your agents. 102 | 103 | OPAL brings open-policy up to the speed needed by live applications. As your application state changes (whether it's via your APIs, DBs, git, S3 or 3rd-party SaaS services), OPAL will make sure your services are always in sync with the authorization data and policy they need (and only those they need). 104 | 105 | Check out OPAL's main site at [OPAL.ac](https://opal.ac). 106 | 107 | simplified 108 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | # When scaling the opal-server to multiple nodes and/or multiple workers, we use 4 | # a *broadcast* channel to sync between all the instances of opal-server. 5 | # Under the hood, this channel is implemented by encode/broadcaster (see link below). 6 | # At the moment, the broadcast channel can be either: postgresdb, redis or kafka. 7 | # The format of the broadcaster URI string (the one we pass to opal server as `OPAL_BROADCAST_URI`) is specified here: 8 | # https://github.com/encode/broadcaster#available-backends 9 | broadcast_channel: 10 | image: postgres:alpine 11 | environment: 12 | - POSTGRES_DB=postgres 13 | - POSTGRES_USER=postgres 14 | - POSTGRES_PASSWORD=postgres 15 | # example postgresdb with data that opal-client can pull and store in OPA cache 16 | example_db: 17 | image: postgres 18 | restart: always 19 | environment: 20 | - POSTGRES_DB=postgres 21 | - POSTGRES_USER=postgres 22 | - POSTGRES_PASSWORD=postgres 23 | logging: 24 | options: 25 | max-size: 10m 26 | max-file: "3" 27 | ports: 28 | - '5438:5432' 29 | volumes: 30 | # copy the sql script to create tables 31 | - ./sql/create_tables.sql:/docker-entrypoint-initdb.d/create_tables.sql 32 | # copy the sql script to fill tables 33 | - ./sql/fill_tables.sql:/docker-entrypoint-initdb.d/fill_tables.sql 34 | opal_server: 35 | # by default we run opal-server from latest official image 36 | image: permitio/opal-server:latest 37 | environment: 38 | # the broadcast backbone uri used by opal server workers (see comments above for: broadcast_channel) 39 | - OPAL_BROADCAST_URI=postgres://postgres:postgres@broadcast_channel:5432/postgres 40 | # number of uvicorn workers to run inside the opal-server container 41 | - UVICORN_NUM_WORKERS=4 42 | # the git repo hosting our policy 43 | # - if this repo is not public, you can pass an ssh key via `OPAL_POLICY_REPO_SSH_KEY`) 44 | # - the repo we pass in this example is *public* and acts as an example repo with dummy rego policy 45 | # - for more info, see: https://github.com/permitio/opal/blob/master/docs/HOWTO/track_a_git_repo.md 46 | - OPAL_POLICY_REPO_URL=https://github.com/permitio/opal-example-policy-repo 47 | # in this example we will use a polling interval of 30 seconds to check for new policy updates (git commits affecting the rego policy). 48 | # however, it is better to utilize a git *webhook* to trigger the server to check for changes only when the repo has new commits. 49 | # for more info see: https://github.com/permitio/opal/blob/master/docs/HOWTO/track_a_git_repo.md 50 | - OPAL_POLICY_REPO_POLLING_INTERVAL=30 51 | # configures from where the opal client should initially fetch data (when it first goes up, after disconnection, etc). 52 | # the data sources represents from where the opal clients should get a "complete picture" of the data they need. 53 | # after the initial sources are fetched, the client will subscribe only to update notifications sent by the server. 54 | # Add "fetch_key":"city_id" to get an object with keys of city_id, rather than a list 55 | - OPAL_DATA_CONFIG_SOURCES={"config":{"entries":[{"url":"postgresql://postgres@example_db:5432/postgres","config":{"fetcher":"PostgresFetchProvider","query":"SELECT * from city;","connection_params":{"password":"postgres"}},"topics":["policy_data"],"dst_path":"cities"}]}} 56 | ports: 57 | # exposes opal server on the host machine, you can access the server at: http://localhost:7002 58 | - "7002:7002" 59 | depends_on: 60 | - broadcast_channel 61 | opal_client: 62 | # by default we run opal-client from latest official image 63 | build: 64 | context: . 65 | environment: 66 | - OPAL_SERVER_URL=http://opal_server:7002 67 | - OPAL_FETCH_PROVIDER_MODULES=opal_common.fetcher.providers,opal_fetcher_postgres.provider 68 | - OPAL_INLINE_OPA_LOG_FORMAT=http 69 | ports: 70 | # exposes opal client on the host machine, you can access the client at: http://localhost:7000 71 | - "7766:7000" 72 | # exposes the OPA agent (being run by OPAL) on the host machine 73 | # you can access the OPA api that you know and love at: http://localhost:8181 74 | # OPA api docs are at: https://www.openpolicyagent.org/docs/latest/rest-api/ 75 | - "8181:8181" 76 | depends_on: 77 | - opal_server 78 | - example_db 79 | # this command is not necessary when deploying OPAL for real, it is simply a trick for dev environments 80 | # to make sure that opal-server is already up before starting the client. 81 | command: sh -c "wait-for-it --service opal_server:7002 --service example_db:5432 --timeout 60 -- /start.sh" 82 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=7.1"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = 'opal-fetcher-postgres' 7 | authors = [ 8 | { name="Asaf Cohen", email="asaf@permit.io" }, 9 | ] 10 | description="An OPAL fetch provider to bring authorization state from Postgres DB." 11 | readme = "README.md" 12 | keywords = [ "Open Policy Agent", "OPA", "OPAL", "Open Policy Administration Layer", "Postgres", "Permit.io" ] 13 | requires-python = ">=3.7" 14 | license = {text = "Apache-2.0"} 15 | classifiers = [ 16 | 'Operating System :: OS Independent', 17 | 'License :: OSI Approved :: Apache Software License', 18 | "Topic :: Software Development :: Libraries :: Python Modules", 19 | 'Programming Language :: Python', 20 | 'Programming Language :: Python :: 3', 21 | 'Programming Language :: Python :: 3.7', 22 | 'Programming Language :: Python :: 3.8', 23 | 'Programming Language :: Python :: 3.9', 24 | ] 25 | dependencies = [ 26 | 'opal-common>=0.1.11', 27 | 'asyncpg', 28 | "pydantic", 29 | 'tenacity', 30 | 'click', 31 | ] 32 | dynamic = ["version"] 33 | 34 | [project.urls] 35 | "Source" = "https://github.com/permitio/opal-fetcher-postgres" 36 | "Bug Tracker" = "https://github.com/permitio/opal-fetcher-postgres/issues" 37 | 38 | [tool.setuptools_scm] 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="opal_fetcher_postgres", 5 | version="0.0.4", 6 | ) 7 | -------------------------------------------------------------------------------- /sql/create_tables.sql: -------------------------------------------------------------------------------- 1 | -- Creation of product table 2 | CREATE TABLE IF NOT EXISTS product ( 3 | product_id INT NOT NULL, 4 | name varchar(250) NOT NULL, 5 | PRIMARY KEY (product_id) 6 | ); 7 | 8 | -- Creation of country table 9 | CREATE TABLE IF NOT EXISTS country ( 10 | country_id INT NOT NULL, 11 | country_name varchar(450) NOT NULL, 12 | PRIMARY KEY (country_id) 13 | ); 14 | 15 | -- Creation of city table 16 | CREATE TABLE IF NOT EXISTS city ( 17 | city_id INT NOT NULL, 18 | city_name varchar(450) NOT NULL, 19 | country_id INT NOT NULL, 20 | PRIMARY KEY (city_id), 21 | CONSTRAINT fk_country 22 | FOREIGN KEY(country_id) 23 | REFERENCES country(country_id) 24 | ); 25 | 26 | -- Creation of store table 27 | CREATE TABLE IF NOT EXISTS store ( 28 | store_id INT NOT NULL, 29 | name varchar(250) NOT NULL, 30 | city_id INT NOT NULL, 31 | PRIMARY KEY (store_id), 32 | CONSTRAINT fk_city 33 | FOREIGN KEY(city_id) 34 | REFERENCES city(city_id) 35 | ); 36 | 37 | -- Creation of user table 38 | CREATE TABLE IF NOT EXISTS users ( 39 | user_id INT NOT NULL, 40 | name varchar(250) NOT NULL, 41 | PRIMARY KEY (user_id) 42 | ); 43 | 44 | -- Creation of status_name table 45 | CREATE TABLE IF NOT EXISTS status_name ( 46 | status_name_id INT NOT NULL, 47 | status_name varchar(450) NOT NULL, 48 | PRIMARY KEY (status_name_id) 49 | ); 50 | 51 | -- Creation of sale table 52 | CREATE TABLE IF NOT EXISTS sale ( 53 | sale_id varchar(200) NOT NULL, 54 | amount DECIMAL(20,3) NOT NULL, 55 | date_sale TIMESTAMP, 56 | product_id INT NOT NULL, 57 | user_id INT NOT NULL, 58 | store_id INT NOT NULL, 59 | PRIMARY KEY (sale_id), 60 | CONSTRAINT fk_product 61 | FOREIGN KEY(product_id) 62 | REFERENCES product(product_id), 63 | CONSTRAINT fk_user 64 | FOREIGN KEY(user_id) 65 | REFERENCES users(user_id), 66 | CONSTRAINT fk_store 67 | FOREIGN KEY(store_id) 68 | REFERENCES store(store_id) 69 | ); 70 | 71 | -- Creation of order_status table 72 | CREATE TABLE IF NOT EXISTS order_status ( 73 | order_status_id varchar(200) NOT NULL, 74 | update_at TIMESTAMP, 75 | sale_id varchar(200) NOT NULL, 76 | status_name_id INT NOT NULL, 77 | PRIMARY KEY (order_status_id), 78 | CONSTRAINT fk_sale 79 | FOREIGN KEY(sale_id) 80 | REFERENCES sale(sale_id), 81 | CONSTRAINT fk_status_name 82 | FOREIGN KEY(status_name_id) 83 | REFERENCES status_name(status_name_id) 84 | ); 85 | -------------------------------------------------------------------------------- /sql/fill_tables.sql: -------------------------------------------------------------------------------- 1 | -- Set params 2 | set session my.number_of_sales = '2000000'; 3 | set session my.number_of_users = '500000'; 4 | set session my.number_of_products = '300'; 5 | set session my.number_of_stores = '500'; 6 | set session my.number_of_coutries = '100'; 7 | set session my.number_of_cities = '30'; 8 | set session my.status_names = '5'; 9 | set session my.start_date = '2019-01-01 00:00:00'; 10 | set session my.end_date = '2020-02-01 00:00:00'; 11 | 12 | -- load the pgcrypto extension to gen_random_uuid () 13 | CREATE EXTENSION pgcrypto; 14 | 15 | -- Filling of products 16 | INSERT INTO product 17 | select id, concat('Product ', id) 18 | FROM GENERATE_SERIES(1, current_setting('my.number_of_products')::int) as id; 19 | 20 | -- Filling of countries 21 | INSERT INTO country 22 | select id, concat('Country ', id) 23 | FROM GENERATE_SERIES(1, current_setting('my.number_of_coutries')::int) as id; 24 | 25 | -- Filling of cities 26 | INSERT INTO city 27 | select id 28 | , concat('City ', id) 29 | , floor(random() * (current_setting('my.number_of_coutries')::int) + 1)::int 30 | FROM GENERATE_SERIES(1, current_setting('my.number_of_cities')::int) as id; 31 | 32 | -- Filling of stores 33 | INSERT INTO store 34 | select id 35 | , concat('Store ', id) 36 | , floor(random() * (current_setting('my.number_of_cities')::int) + 1)::int 37 | FROM GENERATE_SERIES(1, current_setting('my.number_of_stores')::int) as id; 38 | 39 | -- Filling of users 40 | INSERT INTO users 41 | select id 42 | , concat('User ', id) 43 | FROM GENERATE_SERIES(1, current_setting('my.number_of_users')::int) as id; 44 | 45 | -- Filling of users 46 | INSERT INTO status_name 47 | select status_name_id 48 | , concat('Status Name ', status_name_id) 49 | FROM GENERATE_SERIES(1, current_setting('my.status_names')::int) as status_name_id; 50 | 51 | -- Filling of sales 52 | INSERT INTO sale 53 | select gen_random_uuid () 54 | , round(CAST(float8 (random() * 10000) as numeric), 3) 55 | , TO_TIMESTAMP(start_date, 'YYYY-MM-DD HH24:MI:SS') + 56 | random()* (TO_TIMESTAMP(end_date, 'YYYY-MM-DD HH24:MI:SS') 57 | - TO_TIMESTAMP(start_date, 'YYYY-MM-DD HH24:MI:SS')) 58 | , floor(random() * (current_setting('my.number_of_products')::int) + 1)::int 59 | , floor(random() * (current_setting('my.number_of_users')::int) + 1)::int 60 | , floor(random() * (current_setting('my.number_of_stores')::int) + 1)::int 61 | FROM GENERATE_SERIES(1, current_setting('my.number_of_sales')::int) as id 62 | , current_setting('my.start_date') as start_date 63 | , current_setting('my.end_date') as end_date; 64 | 65 | -- Filling of order_status 66 | INSERT INTO order_status 67 | select gen_random_uuid () 68 | , date_sale + random()* (date_sale + '5 days' - date_sale) 69 | , sale_id 70 | , floor(random() * (current_setting('my.status_names')::int) + 1)::int 71 | from sale; 72 | -------------------------------------------------------------------------------- /src/opal_fetcher_postgres/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/permitio/opal-fetcher-postgres/f96e8b6dc2079f8f1b441041c08a608cb1f37d7e/src/opal_fetcher_postgres/__init__.py -------------------------------------------------------------------------------- /src/opal_fetcher_postgres/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple fetch provider for postgres db. 3 | 4 | This fetcher also serves as an example how to build custom OPAL Fetch Providers. 5 | """ 6 | from typing import Optional, List 7 | 8 | import asyncpg 9 | from asyncpg.transaction import Transaction 10 | from asyncpg.exceptions import DataError 11 | from pydantic import BaseModel, Field 12 | from tenacity import wait, stop, retry_unless_exception_type 13 | 14 | from opal_common.fetcher.fetch_provider import BaseFetchProvider 15 | from opal_common.fetcher.events import FetcherConfig, FetchEvent 16 | from opal_common.logger import logger 17 | 18 | 19 | class PostgresConnectionParams(BaseModel): 20 | """ 21 | if one does not want to pass all postgres arguments in the dsn (in OPAL - the url is the dsn), 22 | one can also use this dict to pass specific arguments. 23 | """ 24 | 25 | database: Optional[str] = Field(None, description="the database name") 26 | user: Optional[str] = Field(None, description="user name used to authenticate") 27 | password: Optional[str] = Field(None, description="password used to authenticate") 28 | host: Optional[str] = Field( 29 | None, 30 | description="database host address (defaults to UNIX socket if not provided)", 31 | ) 32 | port: Optional[str] = Field( 33 | None, description="connection port number (defaults to 5432 if not provided)" 34 | ) 35 | 36 | 37 | class PostgresFetcherConfig(FetcherConfig): 38 | """ 39 | Config for PostgresFetchProvider, instance of `FetcherConfig`. 40 | 41 | When an OPAL client receives an update, it contains a list of `DataSourceEntry` objects. 42 | Each `DataSourceEntry` has a `config` key - which is usually an instance of a subclass of `FetcherConfig`. 43 | 44 | When writing a custom provider, you must: 45 | - derive your class (inherit) from FetcherConfig 46 | - override the `fetcher` key with your fetcher class name 47 | - (optional): add any fields relevant to a data entry of your fetcher. 48 | - In this example: since we pull data from PostgreSQL - we added a `query` key to hold the SQL query. 49 | """ 50 | 51 | fetcher: str = "PostgresFetchProvider" 52 | connection_params: Optional[PostgresConnectionParams] = Field( 53 | None, 54 | description="these params can override or complement parts of the dsn (connection string)", 55 | ) 56 | query: str = Field( 57 | ..., description="the query to run against postgres in order to fetch the data" 58 | ) 59 | fetch_one: bool = Field( 60 | False, 61 | description="whether we fetch only one row from the results of the SELECT query", 62 | ) 63 | fetch_key: str = Field( 64 | None, 65 | description="column name to use as key to transform the data to Object format rather than list/array", 66 | ) 67 | 68 | 69 | class PostgresFetchEvent(FetchEvent): 70 | """ 71 | A FetchEvent shape for the Postgres Fetch Provider. 72 | 73 | When writing a custom provider, you must create a custom FetchEvent subclass, just like this class. 74 | In your own class, you must set the value of the `fetcher` key to be your custom provider class name. 75 | """ 76 | 77 | fetcher: str = "PostgresFetchProvider" 78 | config: PostgresFetcherConfig = None 79 | 80 | 81 | class PostgresFetchProvider(BaseFetchProvider): 82 | """ 83 | An OPAL fetch provider for postgres. 84 | 85 | We fetch data from a postgres database by running a SELECT query, 86 | transforming the results to json and dumping the results into the policy store. 87 | 88 | When writing a custom provider, you must: 89 | - derive your provider class (inherit) from BaseFetchProvider 90 | - create a custom config class, as shown above, that derives from FetcherConfig 91 | - create a custom event class, as shown above, that derives from FetchEvent 92 | 93 | At minimum, your custom provider class must implement: 94 | - __init__() - and call super().__init__(event) 95 | - parse_event() - this method gets a `FetchEvent` object and must transform this object to *your own custom event class*. 96 | - Notice that `FetchEvent` is the base class 97 | - Notice that `PostgresFetchEvent` is the custom event class 98 | - _fetch_() - your custom fetch method, can use the data from your event 99 | and config to figure out *what and how to fetch* and actually do it. 100 | - _process_() - if your fetched data requires some processing, you should do it here. 101 | - The return type from this method must be json-able, i.e: can be serialized to json. 102 | 103 | You may need to implement: 104 | - __aenter__() - if your provider has state that needs to be cleaned up, 105 | (i.e: http session, postgres connection, etc) the state may be initialized in this method. 106 | - __aexit__() - if you initialized stateful objects (i.e: acquired resources) in your __aenter__, you must release them in __aexit__ 107 | """ 108 | 109 | RETRY_CONFIG = { 110 | "wait": wait.wait_random_exponential(), 111 | "stop": stop.stop_after_attempt(10), 112 | "retry": retry_unless_exception_type( 113 | DataError 114 | ), # query error (i.e: invalid table, etc) 115 | "reraise": True, 116 | } 117 | 118 | def __init__(self, event: PostgresFetchEvent) -> None: 119 | if event.config is None: 120 | event.config = PostgresFetcherConfig() 121 | super().__init__(event) 122 | self._connection: Optional[asyncpg.Connection] = None 123 | self._transaction: Optional[Transaction] = None 124 | 125 | def parse_event(self, event: FetchEvent) -> PostgresFetchEvent: 126 | return PostgresFetchEvent(**event.dict(exclude={"config"}), config=event.config) 127 | 128 | async def __aenter__(self): 129 | self._event: PostgresFetchEvent # type casting 130 | 131 | dsn: str = self._event.url 132 | connection_params: dict = ( 133 | {} 134 | if self._event.config.connection_params is None 135 | else self._event.config.connection_params.dict(exclude_none=True) 136 | ) 137 | 138 | # connect to the postgres database 139 | self._connection: asyncpg.Connection = await asyncpg.connect( 140 | dsn, **connection_params 141 | ) 142 | # start a readonly transaction (we don't want OPAL client writing data due to security!) 143 | self._transaction: Transaction = self._connection.transaction(readonly=True) 144 | await self._transaction.__aenter__() 145 | 146 | return self 147 | 148 | async def __aexit__(self, exc_type=None, exc_val=None, tb=None): 149 | # End the transaction 150 | if self._transaction is not None: 151 | await self._transaction.__aexit__(exc_type, exc_val, tb) 152 | # Close the connection 153 | if self._connection is not None: 154 | await self._connection.close() 155 | 156 | async def _fetch_(self): 157 | self._event: PostgresFetchEvent # type casting 158 | 159 | if self._event.config is None: 160 | logger.warning( 161 | "incomplete fetcher config: postgres data entries require a query to specify what data to fetch!" 162 | ) 163 | return 164 | 165 | logger.debug(f"{self.__class__.__name__} fetching from {self._url}") 166 | 167 | if self._event.config.fetch_one: 168 | row = await self._connection.fetchrow(self._event.config.query) 169 | return [row] 170 | else: 171 | return await self._connection.fetch(self._event.config.query) 172 | 173 | async def _process_(self, records: List[asyncpg.Record]): 174 | self._event: PostgresFetchEvent # type casting 175 | 176 | # when fetch_one is true, we want to return a dict (and not a list) 177 | if self._event.config.fetch_one: 178 | if records and len(records) > 0: 179 | # we transform the asyncpg record to a dict that we can be later serialized to json 180 | return dict(records[0]) 181 | else: 182 | return {} 183 | else: 184 | if self._event.config.fetch_key is None: 185 | # we transform the asyncpg records to a list-of-dicts that we can be later serialized to json 186 | return [dict(record) for record in records] 187 | else: 188 | # we transform the asyncpg records to a dict-of-dicts that we can be later serialized to json 189 | res_dct = map(lambda i: (records[i][self._event.config.fetch_key], dict(records[i])), range(len(records))) 190 | return dict(res_dct) 191 | --------------------------------------------------------------------------------