├── .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 |
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 |
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 |
--------------------------------------------------------------------------------