├── .coveragerc ├── .github ├── semantic.yml └── workflows │ └── build.yml ├── .gitignore ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── casbin_sqlalchemy_adapter ├── __init__.py └── adapter.py ├── examples └── softdelete.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── rbac_model.conf ├── rbac_policy.csv ├── test_adapter.py └── test_adapter_softdelete.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = casbin_sqlalchemy_adapter/* -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate the PR title AND all the commits 2 | titleAndCommits: true 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | 8 | jobs: 9 | test: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ['3.10', '3.11', '3.12'] 15 | os: [ubuntu-latest] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | pip install -r requirements.txt 29 | pip install coveralls 30 | 31 | - name: Run tests 32 | run: coverage run -m unittest discover -s tests -t tests 33 | 34 | - name: Upload coverage data to coveralls.io 35 | run: coveralls --service=github 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | COVERALLS_FLAG_NAME: ${{ matrix.os }} - ${{ matrix.python-version }} 39 | COVERALLS_PARALLEL: true 40 | 41 | lint: 42 | name: Run Linters 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v2 47 | 48 | - name: Super-Linter 49 | uses: github/super-linter@v4.2.2 50 | env: 51 | VALIDATE_PYTHON_BLACK: true 52 | DEFAULT_BRANCH: master 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | coveralls: 56 | name: Indicate completion to coveralls.io 57 | needs: test 58 | runs-on: ubuntu-latest 59 | container: python:3-slim 60 | steps: 61 | - name: Finished 62 | run: | 63 | pip3 install --upgrade coveralls 64 | coveralls --finish 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | 68 | release: 69 | name: Release 70 | runs-on: ubuntu-latest 71 | needs: [ test, coveralls ] 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v2 75 | with: 76 | fetch-depth: 0 77 | 78 | - name: Setup Node.js 79 | uses: actions/setup-node@v2 80 | with: 81 | node-version: '20' 82 | 83 | - name: Setup 84 | run: npm install -g semantic-release @semantic-release/github @semantic-release/changelog @semantic-release/commit-analyzer @semantic-release/git @semantic-release/release-notes-generator semantic-release-pypi 85 | 86 | - name: Set up python 87 | uses: actions/setup-python@v2 88 | with: 89 | python-version: '3.12' 90 | 91 | - name: Install setuptools 92 | run: python -m pip install --upgrade setuptools wheel twine 93 | 94 | - name: Release 95 | env: 96 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 98 | run: npx semantic-release 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | .idea 106 | 107 | # vscode settings 108 | .vscode 109 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": "master", 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "semantic-release-pypi", 7 | "@semantic-release/github", 8 | [ 9 | "@semantic-release/changelog", 10 | { 11 | "changelogFile": "CHANGELOG.md", 12 | "changelogTitle": "# Semantic Versioning Changelog" 13 | } 14 | ], 15 | [ 16 | "@semantic-release/git", 17 | { 18 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 19 | "assets": ["CHANGELOG.md", "setup.py", "setup.cfg"] 20 | } 21 | ] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Semantic Versioning Changelog 2 | 3 | # [1.4.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v1.3.0...v1.4.0) (2024-07-08) 4 | 5 | 6 | ### Features 7 | 8 | * support for soft deletion of casbin rules ([#72](https://github.com/pycasbin/sqlalchemy-adapter/issues/72)) ([8911c16](https://github.com/pycasbin/sqlalchemy-adapter/commit/8911c1680849b9f2020f73aed0f04d81f24686d7)) 9 | 10 | # [1.3.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v1.2.0...v1.3.0) (2024-07-06) 11 | 12 | 13 | ### Features 14 | 15 | * Add the option to suppress MetaData.create_all call ([e99744f](https://github.com/pycasbin/sqlalchemy-adapter/commit/e99744f39dbe344de41e5a87b5057ea1c65207e4)) 16 | 17 | # [1.2.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v1.1.0...v1.2.0) (2024-05-30) 18 | 19 | 20 | ### Features 21 | 22 | * nested session causes deadlock ([#67](https://github.com/pycasbin/sqlalchemy-adapter/issues/67)) ([d4eedda](https://github.com/pycasbin/sqlalchemy-adapter/commit/d4eedda8422b8256e708c5b5041c59a5e6dfbd99)) 23 | 24 | # [1.1.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v1.0.0...v1.1.0) (2024-03-28) 25 | 26 | 27 | ### Features 28 | 29 | * DeclarativeBase import fails with SQLAlchemy 1.x ([#66](https://github.com/pycasbin/sqlalchemy-adapter/issues/66)) ([8b03bab](https://github.com/pycasbin/sqlalchemy-adapter/commit/8b03bab47f227e4fd16ac8f3694a8d3dbb60a17e)) 30 | 31 | # [1.0.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.7.0...v1.0.0) (2024-03-28) 32 | 33 | 34 | ### Features 35 | 36 | * upgrade CI Python version to 3.12 ([02d357f](https://github.com/pycasbin/sqlalchemy-adapter/commit/02d357f4927b5efe991e965e419b84e8365cf035)) 37 | 38 | 39 | ### BREAKING CHANGES 40 | 41 | * trigger major release 42 | 43 | # [0.7.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.6.0...v0.7.0) (2024-03-02) 44 | 45 | 46 | ### Features 47 | 48 | * update Python version to 3.9+ ([dec0ff8](https://github.com/pycasbin/sqlalchemy-adapter/commit/dec0ff8d51cdabb681a03358a7753c68ec3b7eeb)) 49 | 50 | # [0.6.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.5.3...v0.6.0) (2024-03-01) 51 | 52 | 53 | ### Features 54 | 55 | * replace declarative_base -> DeclarativeBase for sqlalchemy 2.0 migration ([#65](https://github.com/pycasbin/sqlalchemy-adapter/issues/65)) ([a737cd8](https://github.com/pycasbin/sqlalchemy-adapter/commit/a737cd8b1ec0bd47125aa986920f5704b076a84d)) 56 | 57 | ## [0.5.3](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.5.2...v0.5.3) (2024-03-01) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * update CI version to Node.js 20 ([2f91fbc](https://github.com/pycasbin/sqlalchemy-adapter/commit/2f91fbc7b12ec61ee24a019879ac90446137d40b)) 63 | 64 | ## [0.5.2](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.5.1...v0.5.2) (2023-05-22) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * Stop including README as top-level data file in package ([#63](https://github.com/pycasbin/sqlalchemy-adapter/issues/63)) ([206be72](https://github.com/pycasbin/sqlalchemy-adapter/commit/206be72ddbca38a45a02cd3ed63cd31e6158df9c)) 70 | 71 | ## [0.5.1](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.5.0...v0.5.1) (2023-04-16) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * fix CI typo ([7c17a97](https://github.com/pycasbin/sqlalchemy-adapter/commit/7c17a97a5bf0e928d1c2fe7bac0eb751da90ac58)) 77 | * update CI script versions ([72ecd76](https://github.com/pycasbin/sqlalchemy-adapter/commit/72ecd767efd17e8a859d8014a63ee287ff4020e2)) 78 | 79 | # [0.5.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.4.2...v0.5.0) (2022-07-17) 80 | 81 | 82 | ### Features 83 | 84 | * update_filtered_policies ([6799bc8](https://github.com/pycasbin/sqlalchemy-adapter/commit/6799bc8d7089956d640eb253ac171eb661573576)) 85 | 86 | ## [0.4.2](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.4.1...v0.4.2) (2021-09-09) 87 | 88 | 89 | ### Bug Fixes 90 | 91 | * make filter query works with custom db_class ([8ee69bc](https://github.com/pycasbin/sqlalchemy-adapter/commit/8ee69bc5e8c07fe24fb0b65d43593fdea101ac22)) 92 | 93 | ## [0.4.1](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.4.0...v0.4.1) (2021-09-08) 94 | 95 | 96 | ### Bug Fixes 97 | 98 | * fix db_class not vaild ([4effb2d](https://github.com/pycasbin/sqlalchemy-adapter/commit/4effb2dfe96f2b184f9f14984dacdc3a8edfa670)) 99 | 100 | # [0.4.0](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.3.2...v0.4.0) (2021-07-21) 101 | 102 | 103 | ### Features 104 | 105 | * finish up update_policy and update_policies ([#48](https://github.com/pycasbin/sqlalchemy-adapter/issues/48)) ([84ed6ee](https://github.com/pycasbin/sqlalchemy-adapter/commit/84ed6eea02a3200807f01c194f83a058bac038ac)) 106 | 107 | ## [0.3.2](https://github.com/pycasbin/sqlalchemy-adapter/compare/v0.3.1...v0.3.2) (2021-07-18) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * migrate from travis-ci to Github Actions ([60b3253](https://github.com/pycasbin/sqlalchemy-adapter/commit/60b3253cfadc63aa82aa346a29e7cb51cda3a405)) 113 | * possibly wrong behavior for remove_policies ([#40](https://github.com/pycasbin/sqlalchemy-adapter/issues/40)) ([1449199](https://github.com/pycasbin/sqlalchemy-adapter/commit/14491999a8c1239d2ee8d3e2a40257e654856431)) 114 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SQLAlchemy Adapter for PyCasbin 2 | ==== 3 | 4 | [![GitHub Actions](https://github.com/pycasbin/sqlalchemy-adapter/workflows/build/badge.svg?branch=master)](https://github.com/pycasbin/sqlalchemy-adapter/actions) 5 | [![Coverage Status](https://coveralls.io/repos/github/pycasbin/sqlalchemy-adapter/badge.svg)](https://coveralls.io/github/pycasbin/sqlalchemy-adapter) 6 | [![Version](https://img.shields.io/pypi/v/casbin_sqlalchemy_adapter.svg)](https://pypi.org/project/casbin_sqlalchemy_adapter/) 7 | [![PyPI - Wheel](https://img.shields.io/pypi/wheel/casbin_sqlalchemy_adapter.svg)](https://pypi.org/project/casbin_sqlalchemy_adapter/) 8 | [![Pyversions](https://img.shields.io/pypi/pyversions/casbin_sqlalchemy_adapter.svg)](https://pypi.org/project/casbin_sqlalchemy_adapter/) 9 | [![Download](https://img.shields.io/pypi/dm/casbin_sqlalchemy_adapter.svg)](https://pypi.org/project/casbin_sqlalchemy_adapter/) 10 | [![License](https://img.shields.io/pypi/l/casbin_sqlalchemy_adapter.svg)](https://pypi.org/project/casbin_sqlalchemy_adapter/) 11 | 12 | SQLAlchemy Adapter is the [SQLAlchemy](https://www.sqlalchemy.org) adapter for [PyCasbin](https://github.com/casbin/pycasbin). With this library, Casbin can load policy from SQLAlchemy supported database or save policy to it. 13 | 14 | Based on [Officially Supported Databases](http://www.sqlalchemy.org/), The current supported databases are: 15 | 16 | - PostgreSQL 17 | - MySQL 18 | - SQLite 19 | - Oracle 20 | - Microsoft SQL Server 21 | - Firebird 22 | - Sybase 23 | 24 | ## Installation 25 | 26 | ``` 27 | pip install casbin_sqlalchemy_adapter 28 | ``` 29 | 30 | ## Simple Example 31 | 32 | ```python 33 | import casbin_sqlalchemy_adapter 34 | import casbin 35 | 36 | adapter = casbin_sqlalchemy_adapter.Adapter('sqlite:///test.db') 37 | 38 | e = casbin.Enforcer('path/to/model.conf', adapter) 39 | 40 | sub = "alice" # the user that wants to access a resource. 41 | obj = "data1" # the resource that is going to be accessed. 42 | act = "read" # the operation that the user performs on the resource. 43 | 44 | if e.enforce(sub, obj, act): 45 | # permit alice to read data1 46 | pass 47 | else: 48 | # deny the request, show an error 49 | pass 50 | ``` 51 | 52 | ## Soft Delete example 53 | 54 | Soft Delete for casbin rules is supported, only when using a custom casbin rule model. 55 | The Soft Delete mechanism is enabled by passing the attribute of the flag indicating whether 56 | a rule is deleted to `db_class_softdelete_attribute`. 57 | That attribute needs to be of type `sqlalchemy.Boolean`. 58 | 59 | ```python 60 | adapter = Adapter( 61 | engine, 62 | db_class=MyCustomCasbinRuleModel, 63 | db_class_softdelete_attribute=MyCustomCasbinRuleModel.is_deleted, 64 | ) 65 | ``` 66 | 67 | Please be aware that this adapter only sets a flag like `is_deleted` to `True`. 68 | The provided model needs to handle the update of fields like `deleted_by`, `deleted_at`, etc. 69 | An example for this is given in [examples/softdelete.py](examples/softdelete.py). 70 | 71 | ### Getting Help 72 | 73 | - [PyCasbin](https://github.com/casbin/pycasbin) 74 | 75 | ### License 76 | 77 | This project is licensed under the [Apache 2.0 license](LICENSE). 78 | -------------------------------------------------------------------------------- /casbin_sqlalchemy_adapter/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import CasbinRule, Adapter, Base 2 | -------------------------------------------------------------------------------- /casbin_sqlalchemy_adapter/adapter.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import sqlalchemy 4 | from casbin import persist 5 | from sqlalchemy import Column, Integer, String, Boolean 6 | from sqlalchemy import create_engine, or_, not_ 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | # declarative base class 10 | if sqlalchemy.__version__.startswith("1."): 11 | from sqlalchemy.orm import declarative_base 12 | 13 | Base = declarative_base() 14 | 15 | else: 16 | from sqlalchemy.orm import DeclarativeBase 17 | 18 | class Base(DeclarativeBase): 19 | pass 20 | 21 | 22 | class CasbinRule(Base): 23 | __tablename__ = "casbin_rule" 24 | 25 | id = Column(Integer, primary_key=True) 26 | ptype = Column(String(255)) 27 | v0 = Column(String(255)) 28 | v1 = Column(String(255)) 29 | v2 = Column(String(255)) 30 | v3 = Column(String(255)) 31 | v4 = Column(String(255)) 32 | v5 = Column(String(255)) 33 | 34 | def __str__(self): 35 | arr = [self.ptype] 36 | for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5): 37 | if v is None: 38 | break 39 | arr.append(v) 40 | return ", ".join(arr) 41 | 42 | def __repr__(self): 43 | return ''.format(self.id, str(self)) 44 | 45 | 46 | class Filter: 47 | ptype = [] 48 | v0 = [] 49 | v1 = [] 50 | v2 = [] 51 | v3 = [] 52 | v4 = [] 53 | v5 = [] 54 | 55 | 56 | class Adapter(persist.Adapter, persist.adapters.UpdateAdapter): 57 | """the interface for Casbin adapters.""" 58 | 59 | def __init__( 60 | self, 61 | engine, 62 | db_class=None, 63 | db_class_softdelete_attribute=None, 64 | filtered=False, 65 | create_all_models=True, 66 | ): 67 | if isinstance(engine, str): 68 | self._engine = create_engine(engine) 69 | else: 70 | self._engine = engine 71 | 72 | self.softdelete_attribute = None 73 | 74 | if db_class is None: 75 | db_class = CasbinRule 76 | else: 77 | if db_class_softdelete_attribute is not None and not isinstance( 78 | db_class_softdelete_attribute.type, Boolean 79 | ): 80 | msg = f"The type of db_class_softdelete_attribute needs to be {str(Boolean)!r}. " 81 | msg += f"An attribute of type {str(type(db_class_softdelete_attribute.type))!r} was given." 82 | raise ValueError(msg) 83 | # Softdelete is only supported when using custom class 84 | self.softdelete_attribute = db_class_softdelete_attribute 85 | 86 | for attr in ( 87 | "id", 88 | "ptype", 89 | "v0", 90 | "v1", 91 | "v2", 92 | "v3", 93 | "v4", 94 | "v5", 95 | ): # id attr was used by filter 96 | if not hasattr(db_class, attr): 97 | raise Exception(f"{attr} not found in custom DatabaseClass.") 98 | Base.metadata = db_class.metadata 99 | 100 | self._db_class = db_class 101 | self.session_local = sessionmaker(bind=self._engine) 102 | 103 | if create_all_models: 104 | Base.metadata.create_all(self._engine) 105 | self._filtered = filtered 106 | 107 | @contextmanager 108 | def _session_scope(self): 109 | """Provide a transactional scope around a series of operations.""" 110 | session = self.session_local() 111 | try: 112 | yield session 113 | session.commit() 114 | except Exception as e: 115 | session.rollback() 116 | raise e 117 | finally: 118 | session.close() 119 | 120 | def load_policy(self, model): 121 | """loads all policy rules from the storage.""" 122 | with self._session_scope() as session: 123 | query = session.query(self._db_class) 124 | query = self._softdelete_query(query) 125 | lines = query.all() 126 | for line in lines: 127 | persist.load_policy_line(str(line), model) 128 | 129 | def is_filtered(self): 130 | return self._filtered 131 | 132 | def load_filtered_policy(self, model, filter) -> None: 133 | """loads all policy rules from the storage.""" 134 | with self._session_scope() as session: 135 | query = session.query(self._db_class) 136 | query = self._softdelete_query(query) 137 | filters = self.filter_query(query, filter) 138 | filters = filters.all() 139 | 140 | for line in filters: 141 | persist.load_policy_line(str(line), model) 142 | self._filtered = True 143 | 144 | def filter_query(self, querydb, filter): 145 | for attr in ("ptype", "v0", "v1", "v2", "v3", "v4", "v5"): 146 | if len(getattr(filter, attr)) > 0: 147 | querydb = querydb.filter( 148 | getattr(self._db_class, attr).in_(getattr(filter, attr)) 149 | ) 150 | return querydb.order_by(self._db_class.id) 151 | 152 | def _save_policy_line(self, ptype, rule, session=None): 153 | line = self._db_class(ptype=ptype) 154 | for i, v in enumerate(rule): 155 | setattr(line, "v{}".format(i), v) 156 | if session: 157 | session.add(line) 158 | else: 159 | with self._session_scope() as session: 160 | session.add(line) 161 | 162 | def save_policy(self, model): 163 | """saves all policy rules to the storage.""" 164 | 165 | # Use the default strategy when soft delete is not enabled 166 | if self.softdelete_attribute is None: 167 | with self._session_scope() as session: 168 | query = session.query(self._db_class) 169 | query.delete() 170 | for sec in ["p", "g"]: 171 | if sec not in model.model.keys(): 172 | continue 173 | for ptype, ast in model.model[sec].items(): 174 | for rule in ast.policy: 175 | self._save_policy_line(ptype, rule, session=session) 176 | return True 177 | 178 | # Custom stategy for softdelete since it does not make sense to recreate all of the 179 | # entries when using soft delete 180 | with self._session_scope() as session: 181 | query = session.query(self._db_class) 182 | query = self._softdelete_query(query) 183 | 184 | # Delete entries that are not part of the model anymore 185 | lines_before_changes = query.all() 186 | 187 | # Create new entries in the database 188 | for sec in ["p", "g"]: 189 | if sec not in model.model.keys(): 190 | continue 191 | for ptype, ast in model.model[sec].items(): 192 | for rule in ast.policy: 193 | # Filter for rule in the database 194 | filter_query = query.filter(self._db_class.ptype == ptype) 195 | for index, value in enumerate(rule): 196 | v_value = getattr(self._db_class, "v{}".format(index)) 197 | filter_query = filter_query.filter(v_value == value) 198 | # If the rule is not present, create an entry in the database 199 | if filter_query.count() == 0: 200 | self._save_policy_line(ptype, rule, session=session) 201 | 202 | for line in lines_before_changes: 203 | ptype = line.ptype 204 | sec = ptype[0] # derived from persist.load_policy_line function 205 | fields_with_None = [ 206 | line.v0, 207 | line.v1, 208 | line.v2, 209 | line.v3, 210 | line.v4, 211 | line.v5, 212 | ] 213 | rule = [element for element in fields_with_None if element is not None] 214 | # If the the rule is not part of the model, set the deletion flag to True 215 | if not model.has_policy(sec, ptype, rule): 216 | setattr(line, self.softdelete_attribute.name, True) 217 | 218 | return True 219 | 220 | def add_policy(self, sec, ptype, rule): 221 | """adds a policy rule to the storage.""" 222 | self._save_policy_line(ptype, rule) 223 | 224 | def add_policies(self, sec, ptype, rules): 225 | """adds a policy rules to the storage.""" 226 | for rule in rules: 227 | self._save_policy_line(ptype, rule) 228 | 229 | def remove_policy(self, sec, ptype, rule): 230 | """removes a policy rule from the storage.""" 231 | with self._session_scope() as session: 232 | query = session.query(self._db_class) 233 | query = self._softdelete_query(query) 234 | query = query.filter(self._db_class.ptype == ptype) 235 | for i, v in enumerate(rule): 236 | query = query.filter(getattr(self._db_class, "v{}".format(i)) == v) 237 | 238 | if self.softdelete_attribute is None: 239 | r = query.delete() 240 | else: 241 | r = query.update({self.softdelete_attribute: True}) 242 | 243 | return True if r > 0 else False 244 | 245 | def remove_policies(self, sec, ptype, rules): 246 | """remove policy rules from the storage.""" 247 | if not rules: 248 | return 249 | with self._session_scope() as session: 250 | query = session.query(self._db_class) 251 | query = self._softdelete_query(query) 252 | query = query.filter(self._db_class.ptype == ptype) 253 | rules = zip(*rules) 254 | for i, rule in enumerate(rules): 255 | query = query.filter( 256 | or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule) 257 | ) 258 | 259 | if self.softdelete_attribute is None: 260 | query.delete() 261 | else: 262 | query.update({self.softdelete_attribute: True}) 263 | 264 | def remove_filtered_policy(self, sec, ptype, field_index, *field_values): 265 | """removes policy rules that match the filter from the storage. 266 | This is part of the Auto-Save feature. 267 | """ 268 | with self._session_scope() as session: 269 | query = session.query(self._db_class) 270 | query = self._softdelete_query(query) 271 | query = query.filter(self._db_class.ptype == ptype) 272 | 273 | if not (0 <= field_index <= 5): 274 | return False 275 | if not (1 <= field_index + len(field_values) <= 6): 276 | return False 277 | for i, v in enumerate(field_values): 278 | if v != "": 279 | v_value = getattr(self._db_class, "v{}".format(field_index + i)) 280 | query = query.filter(v_value == v) 281 | 282 | if self.softdelete_attribute is None: 283 | r = query.delete() 284 | else: 285 | r = query.update({self.softdelete_attribute: True}) 286 | 287 | return True if r > 0 else False 288 | 289 | def update_policy( 290 | self, sec: str, ptype: str, old_rule: list[str], new_rule: list[str] 291 | ) -> None: 292 | """ 293 | Update the old_rule with the new_rule in the database (storage). 294 | 295 | :param sec: section type 296 | :param ptype: policy type 297 | :param old_rule: the old rule that needs to be modified 298 | :param new_rule: the new rule to replace the old rule 299 | 300 | :return: None 301 | """ 302 | 303 | with self._session_scope() as session: 304 | query = session.query(self._db_class) 305 | query = self._softdelete_query(query) 306 | query = query.filter(self._db_class.ptype == ptype) 307 | 308 | # locate the old rule 309 | for index, value in enumerate(old_rule): 310 | v_value = getattr(self._db_class, "v{}".format(index)) 311 | query = query.filter(v_value == value) 312 | 313 | # need the length of the longest_rule to perform overwrite 314 | longest_rule = old_rule if len(old_rule) > len(new_rule) else new_rule 315 | old_rule_line = query.one() 316 | 317 | # overwrite the old rule with the new rule 318 | for index in range(len(longest_rule)): 319 | if index < len(new_rule): 320 | exec(f"old_rule_line.v{index} = new_rule[{index}]") 321 | else: 322 | exec(f"old_rule_line.v{index} = None") 323 | 324 | def update_policies( 325 | self, 326 | sec: str, 327 | ptype: str, 328 | old_rules: list[list[str]], 329 | new_rules: list[list[str]], 330 | ) -> None: 331 | """ 332 | Update the old_rules with the new_rules in the database (storage). 333 | 334 | :param sec: section type 335 | :param ptype: policy type 336 | :param old_rules: the old rules that need to be modified 337 | :param new_rules: the new rules to replace the old rules 338 | 339 | :return: None 340 | """ 341 | for i in range(len(old_rules)): 342 | self.update_policy(sec, ptype, old_rules[i], new_rules[i]) 343 | 344 | def update_filtered_policies( 345 | self, sec, ptype, new_rules: list[list[str]], field_index, *field_values 346 | ) -> list[list[str]]: 347 | """update_filtered_policies updates all the policies on the basis of the filter.""" 348 | 349 | filter = Filter() 350 | filter.ptype = ptype 351 | 352 | # Creating Filter from the field_index & field_values provided 353 | for i in range(len(field_values)): 354 | if field_index <= i and i < field_index + len(field_values): 355 | setattr(filter, f"v{i}", field_values[i - field_index]) 356 | else: 357 | break 358 | 359 | self._update_filtered_policies(new_rules, filter) 360 | 361 | def _update_filtered_policies(self, new_rules, filter) -> list[list[str]]: 362 | """_update_filtered_policies updates all the policies on the basis of the filter.""" 363 | 364 | with self._session_scope() as session: 365 | # Load old policies 366 | 367 | query = session.query(self._db_class) 368 | query = self._softdelete_query(query) 369 | query = query.filter(self._db_class.ptype == filter.ptype) 370 | filtered_query = self.filter_query(query, filter) 371 | old_rules = filtered_query.all() 372 | 373 | # Delete old policies 374 | 375 | self.remove_policies("p", filter.ptype, old_rules) 376 | 377 | # Insert new policies 378 | 379 | self.add_policies("p", filter.ptype, new_rules) 380 | 381 | # return deleted rules 382 | 383 | return old_rules 384 | 385 | def _softdelete_query(self, query): 386 | query_softdelete = query 387 | if self.softdelete_attribute is not None: 388 | query_softdelete = query_softdelete.where(not_(self.softdelete_attribute)) 389 | return query_softdelete 390 | -------------------------------------------------------------------------------- /examples/softdelete.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, UTC 2 | 3 | import casbin 4 | from casbin_sqlalchemy_adapter import Base, Adapter 5 | from sqlalchemy import false, Column, DateTime, String, Integer, Boolean 6 | from sqlalchemy.engine.default import DefaultExecutionContext 7 | 8 | from some_user_library import get_current_user_id 9 | 10 | 11 | def _deleted_at_default(context: DefaultExecutionContext) -> datetime | None: 12 | current_parameters = context.get_current_parameters() 13 | if current_parameters.get("is_deleted"): 14 | return datetime.now(UTC) 15 | else: 16 | return None 17 | 18 | 19 | def _deleted_by_default(context: DefaultExecutionContext) -> int | None: 20 | current_parameters = context.get_current_parameters() 21 | if current_parameters.get("is_deleted"): 22 | return get_current_user_id() 23 | else: 24 | return None 25 | 26 | 27 | class BaseModel(Base): 28 | __abstract__ = True 29 | 30 | created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) 31 | updated_at = Column( 32 | DateTime, 33 | default=lambda: datetime.now(UTC), 34 | onupdate=lambda: datetime.now(UTC), 35 | nullable=False, 36 | ) 37 | deleted_at = Column( 38 | DateTime, 39 | default=_deleted_at_default, 40 | onupdate=_deleted_at_default, 41 | nullable=True, 42 | ) 43 | 44 | created_by = Column(Integer, default=get_current_user_id, nullable=False) 45 | updated_by = Column( 46 | Integer, 47 | default=get_current_user_id, 48 | onupdate=get_current_user_id, 49 | nullable=False, 50 | ) 51 | deleted_by = Column( 52 | Integer, 53 | default=_deleted_by_default, 54 | onupdate=_deleted_by_default, 55 | nullable=True, 56 | ) 57 | is_deleted = Column( 58 | Boolean, 59 | default=False, 60 | server_default=false(), 61 | index=True, 62 | nullable=False, 63 | ) 64 | 65 | 66 | class CasbinSoftDeleteRule(BaseModel): 67 | __tablename__ = "casbin_rule" 68 | 69 | id = Column(Integer, primary_key=True) 70 | ptype = Column(String(255)) 71 | v0 = Column(String(255)) 72 | v1 = Column(String(255)) 73 | v2 = Column(String(255)) 74 | v3 = Column(String(255)) 75 | v4 = Column(String(255)) 76 | v5 = Column(String(255)) 77 | 78 | 79 | engine = your_engine_factory() 80 | # Initialize the Adapter, pass your custom CasbinRule model 81 | # and pass the Boolean field indicating whether a rule is deleted or not 82 | # your model needs to handle the update of fields 83 | # 'updated_by', 'updated_at', 'deleted_by', etc. 84 | adapter = Adapter( 85 | engine, 86 | CasbinSoftDeleteRule, 87 | CasbinSoftDeleteRule.is_deleted, 88 | ) 89 | # Create the Enforcer, etc. 90 | e = casbin.Enforcer("path/to/model.conf", adapter) 91 | ... 92 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | casbin>=0.8.1 2 | SQLAlchemy>=1.2.18 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = 1.4.0 3 | 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages, __version__ 2 | from os import path 3 | 4 | desc_file = "README.md" 5 | 6 | with open(desc_file, "r") as fh: 7 | long_description = fh.read() 8 | 9 | here = path.abspath(path.dirname(__file__)) 10 | # get the dependencies and installs 11 | with open(path.join(here, "requirements.txt"), encoding="utf-8") as f: 12 | all_reqs = f.read().split("\n") 13 | 14 | install_requires = [x.strip() for x in all_reqs if "git+" not in x] 15 | 16 | setup( 17 | name="casbin_sqlalchemy_adapter", 18 | author="TechLee", 19 | author_email="techlee@qq.com", 20 | description="SQLAlchemy Adapter for PyCasbin", 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | url="https://github.com/pycasbin/sqlalchemy-adapter", 24 | keywords=[ 25 | "casbin", 26 | "SQLAlchemy", 27 | "casbin-adapter", 28 | "rbac", 29 | "access control", 30 | "abac", 31 | "acl", 32 | "permission", 33 | ], 34 | packages=find_packages(), 35 | install_requires=install_requires, 36 | python_requires=">=3.3", 37 | license="Apache 2.0", 38 | classifiers=[ 39 | "Programming Language :: Python :: 3.9", 40 | "Programming Language :: Python :: 3.10", 41 | "Programming Language :: Python :: 3.11", 42 | "Programming Language :: Python :: 3.12", 43 | "License :: OSI Approved :: Apache Software License", 44 | "Operating System :: OS Independent", 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/rbac_model.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /tests/rbac_policy.csv: -------------------------------------------------------------------------------- 1 | p, alice, data1, read 2 | p, bob, data2, write 3 | p, data2_admin, data2, read 4 | p, data2_admin, data2, write 5 | 6 | g, alice, data2_admin -------------------------------------------------------------------------------- /tests/test_adapter.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | from pathlib import Path 4 | 5 | import casbin 6 | from sqlalchemy import create_engine, Column, Integer, String 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | from casbin_sqlalchemy_adapter import Adapter 10 | from casbin_sqlalchemy_adapter import Base 11 | from casbin_sqlalchemy_adapter import CasbinRule 12 | from casbin_sqlalchemy_adapter.adapter import Filter 13 | 14 | 15 | class TestConfig(TestCase): 16 | def get_enforcer(self): 17 | engine = create_engine("sqlite://") 18 | # engine = create_engine("sqlite:///" + os.path.split(os.path.realpath(__file__))[0] + "/test.db", echo=True) 19 | adapter = Adapter(engine) 20 | 21 | session = sessionmaker(bind=engine) 22 | Base.metadata.create_all(engine) 23 | s = session() 24 | s.query(CasbinRule).delete() 25 | s.add(CasbinRule(ptype="p", v0="alice", v1="data1", v2="read")) 26 | s.add(CasbinRule(ptype="p", v0="bob", v1="data2", v2="write")) 27 | s.add(CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="read")) 28 | s.add(CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="write")) 29 | s.add(CasbinRule(ptype="g", v0="alice", v1="data2_admin")) 30 | s.commit() 31 | s.close() 32 | 33 | scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) 34 | model_path = scriptdir / "rbac_model.conf" 35 | 36 | return casbin.Enforcer(str(model_path), adapter) 37 | 38 | def test_custom_db_class(self): 39 | class CustomRule(Base): 40 | __tablename__ = "casbin_rule2" 41 | __table_args__ = {"extend_existing": True} 42 | 43 | id = Column(Integer, primary_key=True) 44 | ptype = Column(String(255)) 45 | v0 = Column(String(255)) 46 | v1 = Column(String(255)) 47 | v2 = Column(String(255)) 48 | v3 = Column(String(255)) 49 | v4 = Column(String(255)) 50 | v5 = Column(String(255)) 51 | not_exist = Column(String(255)) 52 | 53 | engine = create_engine("sqlite://") 54 | adapter = Adapter(engine, CustomRule) 55 | 56 | session = sessionmaker(bind=engine) 57 | Base.metadata.create_all(engine) 58 | s = session() 59 | s.add(CustomRule(not_exist="NotNone")) 60 | s.commit() 61 | self.assertEqual(s.query(CustomRule).all()[0].not_exist, "NotNone") 62 | 63 | def test_enforcer_basic(self): 64 | e = self.get_enforcer() 65 | 66 | self.assertTrue(e.enforce("alice", "data1", "read")) 67 | self.assertFalse(e.enforce("alice", "data1", "write")) 68 | self.assertFalse(e.enforce("bob", "data1", "read")) 69 | self.assertFalse(e.enforce("bob", "data1", "write")) 70 | self.assertTrue(e.enforce("bob", "data2", "write")) 71 | self.assertFalse(e.enforce("bob", "data2", "read")) 72 | self.assertTrue(e.enforce("alice", "data2", "read")) 73 | self.assertTrue(e.enforce("alice", "data2", "write")) 74 | 75 | def test_add_policy(self): 76 | e = self.get_enforcer() 77 | 78 | self.assertFalse(e.enforce("eve", "data3", "read")) 79 | res = e.add_policies((("eve", "data3", "read"), ("eve", "data4", "read"))) 80 | self.assertTrue(res) 81 | self.assertTrue(e.enforce("eve", "data3", "read")) 82 | self.assertTrue(e.enforce("eve", "data4", "read")) 83 | 84 | def test_add_policies(self): 85 | e = self.get_enforcer() 86 | 87 | self.assertFalse(e.enforce("eve", "data3", "read")) 88 | res = e.add_permission_for_user("eve", "data3", "read") 89 | self.assertTrue(res) 90 | self.assertTrue(e.enforce("eve", "data3", "read")) 91 | 92 | def test_save_policy(self): 93 | e = self.get_enforcer() 94 | self.assertFalse(e.enforce("alice", "data4", "read")) 95 | 96 | model = e.get_model() 97 | model.clear_policy() 98 | 99 | model.add_policy("p", "p", ["alice", "data4", "read"]) 100 | 101 | adapter = e.get_adapter() 102 | adapter.save_policy(model) 103 | self.assertTrue(e.enforce("alice", "data4", "read")) 104 | 105 | def test_remove_policy(self): 106 | e = self.get_enforcer() 107 | 108 | self.assertFalse(e.enforce("alice", "data5", "read")) 109 | e.add_permission_for_user("alice", "data5", "read") 110 | self.assertTrue(e.enforce("alice", "data5", "read")) 111 | e.delete_permission_for_user("alice", "data5", "read") 112 | self.assertFalse(e.enforce("alice", "data5", "read")) 113 | 114 | def test_remove_policies(self): 115 | e = self.get_enforcer() 116 | 117 | self.assertFalse(e.enforce("alice", "data5", "read")) 118 | self.assertFalse(e.enforce("alice", "data6", "read")) 119 | e.add_policies((("alice", "data5", "read"), ("alice", "data6", "read"))) 120 | self.assertTrue(e.enforce("alice", "data5", "read")) 121 | self.assertTrue(e.enforce("alice", "data6", "read")) 122 | e.remove_policies((("alice", "data5", "read"), ("alice", "data6", "read"))) 123 | self.assertFalse(e.enforce("alice", "data5", "read")) 124 | self.assertFalse(e.enforce("alice", "data6", "read")) 125 | 126 | def test_remove_filtered_policy(self): 127 | e = self.get_enforcer() 128 | 129 | self.assertTrue(e.enforce("alice", "data1", "read")) 130 | e.remove_filtered_policy(1, "data1") 131 | self.assertFalse(e.enforce("alice", "data1", "read")) 132 | 133 | self.assertTrue(e.enforce("bob", "data2", "write")) 134 | self.assertTrue(e.enforce("alice", "data2", "read")) 135 | self.assertTrue(e.enforce("alice", "data2", "write")) 136 | 137 | e.remove_filtered_policy(1, "data2", "read") 138 | 139 | self.assertTrue(e.enforce("bob", "data2", "write")) 140 | self.assertFalse(e.enforce("alice", "data2", "read")) 141 | self.assertTrue(e.enforce("alice", "data2", "write")) 142 | 143 | e.remove_filtered_policy(2, "write") 144 | 145 | self.assertFalse(e.enforce("bob", "data2", "write")) 146 | self.assertFalse(e.enforce("alice", "data2", "write")) 147 | 148 | # e.add_permission_for_user('alice', 'data6', 'delete') 149 | # e.add_permission_for_user('bob', 'data6', 'delete') 150 | # e.add_permission_for_user('eve', 'data6', 'delete') 151 | # self.assertTrue(e.enforce('alice', 'data6', 'delete')) 152 | # self.assertTrue(e.enforce('bob', 'data6', 'delete')) 153 | # self.assertTrue(e.enforce('eve', 'data6', 'delete')) 154 | # e.remove_filtered_policy(0, 'alice', None, 'delete') 155 | # self.assertFalse(e.enforce('alice', 'data6', 'delete')) 156 | # e.remove_filtered_policy(0, None, None, 'delete') 157 | # self.assertFalse(e.enforce('bob', 'data6', 'delete')) 158 | # self.assertFalse(e.enforce('eve', 'data6', 'delete')) 159 | 160 | def test_str(self): 161 | rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") 162 | self.assertEqual(str(rule), "p, alice, data1, read") 163 | rule = CasbinRule(ptype="p", v0="bob", v1="data2", v2="write") 164 | self.assertEqual(str(rule), "p, bob, data2, write") 165 | rule = CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="read") 166 | self.assertEqual(str(rule), "p, data2_admin, data2, read") 167 | rule = CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="write") 168 | self.assertEqual(str(rule), "p, data2_admin, data2, write") 169 | rule = CasbinRule(ptype="g", v0="alice", v1="data2_admin") 170 | self.assertEqual(str(rule), "g, alice, data2_admin") 171 | 172 | def test_repr(self): 173 | rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") 174 | self.assertEqual(repr(rule), '') 175 | engine = create_engine("sqlite://") 176 | 177 | session = sessionmaker(bind=engine) 178 | Base.metadata.create_all(engine) 179 | s = session() 180 | 181 | s.add(rule) 182 | s.commit() 183 | self.assertRegex(repr(rule), r'') 184 | s.close() 185 | 186 | def test_filtered_policy(self): 187 | e = self.get_enforcer() 188 | filter = Filter() 189 | 190 | filter.ptype = ["p"] 191 | e.load_filtered_policy(filter) 192 | self.assertTrue(e.enforce("alice", "data1", "read")) 193 | self.assertFalse(e.enforce("alice", "data1", "write")) 194 | self.assertFalse(e.enforce("alice", "data2", "read")) 195 | self.assertFalse(e.enforce("alice", "data2", "write")) 196 | self.assertFalse(e.enforce("bob", "data1", "read")) 197 | self.assertFalse(e.enforce("bob", "data1", "write")) 198 | self.assertFalse(e.enforce("bob", "data2", "read")) 199 | self.assertTrue(e.enforce("bob", "data2", "write")) 200 | 201 | filter.ptype = [] 202 | filter.v0 = ["alice"] 203 | e.load_filtered_policy(filter) 204 | self.assertTrue(e.enforce("alice", "data1", "read")) 205 | self.assertFalse(e.enforce("alice", "data1", "write")) 206 | self.assertFalse(e.enforce("alice", "data2", "read")) 207 | self.assertFalse(e.enforce("alice", "data2", "write")) 208 | self.assertFalse(e.enforce("bob", "data1", "read")) 209 | self.assertFalse(e.enforce("bob", "data1", "write")) 210 | self.assertFalse(e.enforce("bob", "data2", "read")) 211 | self.assertFalse(e.enforce("bob", "data2", "write")) 212 | self.assertFalse(e.enforce("data2_admin", "data2", "read")) 213 | self.assertFalse(e.enforce("data2_admin", "data2", "write")) 214 | 215 | filter.v0 = ["bob"] 216 | e.load_filtered_policy(filter) 217 | self.assertFalse(e.enforce("alice", "data1", "read")) 218 | self.assertFalse(e.enforce("alice", "data1", "write")) 219 | self.assertFalse(e.enforce("alice", "data2", "read")) 220 | self.assertFalse(e.enforce("alice", "data2", "write")) 221 | self.assertFalse(e.enforce("bob", "data1", "read")) 222 | self.assertFalse(e.enforce("bob", "data1", "write")) 223 | self.assertFalse(e.enforce("bob", "data2", "read")) 224 | self.assertTrue(e.enforce("bob", "data2", "write")) 225 | self.assertFalse(e.enforce("data2_admin", "data2", "read")) 226 | self.assertFalse(e.enforce("data2_admin", "data2", "write")) 227 | 228 | filter.v0 = ["data2_admin"] 229 | e.load_filtered_policy(filter) 230 | self.assertTrue(e.enforce("data2_admin", "data2", "read")) 231 | self.assertTrue(e.enforce("data2_admin", "data2", "read")) 232 | self.assertFalse(e.enforce("alice", "data1", "read")) 233 | self.assertFalse(e.enforce("alice", "data1", "write")) 234 | self.assertFalse(e.enforce("alice", "data2", "read")) 235 | self.assertFalse(e.enforce("alice", "data2", "write")) 236 | self.assertFalse(e.enforce("bob", "data1", "read")) 237 | self.assertFalse(e.enforce("bob", "data1", "write")) 238 | self.assertFalse(e.enforce("bob", "data2", "read")) 239 | self.assertFalse(e.enforce("bob", "data2", "write")) 240 | 241 | filter.v0 = ["alice", "bob"] 242 | e.load_filtered_policy(filter) 243 | self.assertTrue(e.enforce("alice", "data1", "read")) 244 | self.assertFalse(e.enforce("alice", "data1", "write")) 245 | self.assertFalse(e.enforce("alice", "data2", "read")) 246 | self.assertFalse(e.enforce("alice", "data2", "write")) 247 | self.assertFalse(e.enforce("bob", "data1", "read")) 248 | self.assertFalse(e.enforce("bob", "data1", "write")) 249 | self.assertFalse(e.enforce("bob", "data2", "read")) 250 | self.assertTrue(e.enforce("bob", "data2", "write")) 251 | self.assertFalse(e.enforce("data2_admin", "data2", "read")) 252 | self.assertFalse(e.enforce("data2_admin", "data2", "write")) 253 | 254 | filter.v0 = [] 255 | filter.v1 = ["data1"] 256 | e.load_filtered_policy(filter) 257 | self.assertTrue(e.enforce("alice", "data1", "read")) 258 | self.assertFalse(e.enforce("alice", "data1", "write")) 259 | self.assertFalse(e.enforce("alice", "data2", "read")) 260 | self.assertFalse(e.enforce("alice", "data2", "write")) 261 | self.assertFalse(e.enforce("bob", "data1", "read")) 262 | self.assertFalse(e.enforce("bob", "data1", "write")) 263 | self.assertFalse(e.enforce("bob", "data2", "read")) 264 | self.assertFalse(e.enforce("bob", "data2", "write")) 265 | self.assertFalse(e.enforce("data2_admin", "data2", "read")) 266 | self.assertFalse(e.enforce("data2_admin", "data2", "write")) 267 | 268 | filter.v1 = ["data2"] 269 | e.load_filtered_policy(filter) 270 | self.assertFalse(e.enforce("alice", "data1", "read")) 271 | self.assertFalse(e.enforce("alice", "data1", "write")) 272 | self.assertFalse(e.enforce("alice", "data2", "read")) 273 | self.assertFalse(e.enforce("alice", "data2", "write")) 274 | self.assertFalse(e.enforce("bob", "data1", "read")) 275 | self.assertFalse(e.enforce("bob", "data1", "write")) 276 | self.assertFalse(e.enforce("bob", "data2", "read")) 277 | self.assertTrue(e.enforce("bob", "data2", "write")) 278 | self.assertTrue(e.enforce("data2_admin", "data2", "read")) 279 | self.assertTrue(e.enforce("data2_admin", "data2", "write")) 280 | 281 | filter.v1 = [] 282 | filter.v2 = ["read"] 283 | e.load_filtered_policy(filter) 284 | self.assertTrue(e.enforce("alice", "data1", "read")) 285 | self.assertFalse(e.enforce("alice", "data1", "write")) 286 | self.assertFalse(e.enforce("alice", "data2", "read")) 287 | self.assertFalse(e.enforce("alice", "data2", "write")) 288 | self.assertFalse(e.enforce("bob", "data1", "read")) 289 | self.assertFalse(e.enforce("bob", "data1", "write")) 290 | self.assertFalse(e.enforce("bob", "data2", "read")) 291 | self.assertFalse(e.enforce("bob", "data2", "write")) 292 | self.assertTrue(e.enforce("data2_admin", "data2", "read")) 293 | self.assertFalse(e.enforce("data2_admin", "data2", "write")) 294 | 295 | filter.v2 = ["write"] 296 | e.load_filtered_policy(filter) 297 | self.assertFalse(e.enforce("alice", "data1", "read")) 298 | self.assertFalse(e.enforce("alice", "data1", "write")) 299 | self.assertFalse(e.enforce("alice", "data2", "read")) 300 | self.assertFalse(e.enforce("alice", "data2", "write")) 301 | self.assertFalse(e.enforce("bob", "data1", "read")) 302 | self.assertFalse(e.enforce("bob", "data1", "write")) 303 | self.assertFalse(e.enforce("bob", "data2", "read")) 304 | self.assertTrue(e.enforce("bob", "data2", "write")) 305 | self.assertFalse(e.enforce("data2_admin", "data2", "read")) 306 | self.assertTrue(e.enforce("data2_admin", "data2", "write")) 307 | 308 | def test_update_policy(self): 309 | e = self.get_enforcer() 310 | example_p = ["mike", "cookie", "eat"] 311 | 312 | self.assertTrue(e.enforce("alice", "data1", "read")) 313 | e.update_policy(["alice", "data1", "read"], ["alice", "data1", "no_read"]) 314 | self.assertFalse(e.enforce("alice", "data1", "read")) 315 | 316 | self.assertFalse(e.enforce("bob", "data1", "read")) 317 | e.add_policy(example_p) 318 | e.update_policy(example_p, ["bob", "data1", "read"]) 319 | self.assertTrue(e.enforce("bob", "data1", "read")) 320 | 321 | self.assertFalse(e.enforce("bob", "data1", "write")) 322 | e.update_policy(["bob", "data1", "read"], ["bob", "data1", "write"]) 323 | self.assertTrue(e.enforce("bob", "data1", "write")) 324 | 325 | self.assertTrue(e.enforce("bob", "data2", "write")) 326 | e.update_policy(["bob", "data2", "write"], ["bob", "data2", "read"]) 327 | self.assertFalse(e.enforce("bob", "data2", "write")) 328 | 329 | self.assertTrue(e.enforce("bob", "data2", "read")) 330 | e.update_policy(["bob", "data2", "read"], ["carl", "data2", "write"]) 331 | self.assertFalse(e.enforce("bob", "data2", "write")) 332 | 333 | self.assertTrue(e.enforce("carl", "data2", "write")) 334 | e.update_policy(["carl", "data2", "write"], ["carl", "data2", "no_write"]) 335 | self.assertFalse(e.enforce("bob", "data2", "write")) 336 | 337 | def test_update_policies(self): 338 | e = self.get_enforcer() 339 | 340 | old_rule_0 = ["alice", "data1", "read"] 341 | old_rule_1 = ["bob", "data2", "write"] 342 | old_rule_2 = ["data2_admin", "data2", "read"] 343 | old_rule_3 = ["data2_admin", "data2", "write"] 344 | 345 | new_rule_0 = ["alice", "data_test", "read"] 346 | new_rule_1 = ["bob", "data_test", "write"] 347 | new_rule_2 = ["data2_admin", "data_test", "read"] 348 | new_rule_3 = ["data2_admin", "data_test", "write"] 349 | 350 | old_rules = [old_rule_0, old_rule_1, old_rule_2, old_rule_3] 351 | new_rules = [new_rule_0, new_rule_1, new_rule_2, new_rule_3] 352 | 353 | e.update_policies(old_rules, new_rules) 354 | 355 | self.assertFalse(e.enforce("alice", "data1", "read")) 356 | self.assertTrue(e.enforce("alice", "data_test", "read")) 357 | 358 | self.assertFalse(e.enforce("bob", "data2", "write")) 359 | self.assertTrue(e.enforce("bob", "data_test", "write")) 360 | 361 | self.assertFalse(e.enforce("data2_admin", "data2", "read")) 362 | self.assertTrue(e.enforce("data2_admin", "data_test", "read")) 363 | 364 | self.assertFalse(e.enforce("data2_admin", "data2", "write")) 365 | self.assertTrue(e.enforce("data2_admin", "data_test", "write")) 366 | 367 | def test_update_filtered_policies(self): 368 | e = self.get_enforcer() 369 | 370 | e.update_filtered_policies( 371 | [ 372 | ["data2_admin", "data3", "read"], 373 | ["data2_admin", "data3", "write"], 374 | ], 375 | 0, 376 | "data2_admin", 377 | ) 378 | self.assertTrue(e.enforce("data2_admin", "data3", "write")) 379 | self.assertTrue(e.enforce("data2_admin", "data3", "read")) 380 | 381 | e.update_filtered_policies([["alice", "data1", "write"]], 0, "alice") 382 | self.assertTrue(e.enforce("alice", "data1", "write")) 383 | 384 | e.update_filtered_policies([["bob", "data2", "read"]], 0, "bob") 385 | self.assertTrue(e.enforce("bob", "data2", "read")) 386 | -------------------------------------------------------------------------------- /tests/test_adapter_softdelete.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import casbin 5 | from sqlalchemy import create_engine, Column, Boolean, Integer, String 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | from casbin_sqlalchemy_adapter import Adapter 9 | from casbin_sqlalchemy_adapter import Base 10 | from casbin_sqlalchemy_adapter.adapter import Filter 11 | 12 | from tests.test_adapter import TestConfig 13 | 14 | 15 | class CasbinRuleSoftDelete(Base): 16 | __tablename__ = "casbin_rule_soft_delete" 17 | 18 | id = Column(Integer, primary_key=True) 19 | ptype = Column(String(255)) 20 | v0 = Column(String(255)) 21 | v1 = Column(String(255)) 22 | v2 = Column(String(255)) 23 | v3 = Column(String(255)) 24 | v4 = Column(String(255)) 25 | v5 = Column(String(255)) 26 | 27 | is_deleted = Column(Boolean, default=False, index=True, nullable=False) 28 | 29 | def __str__(self): 30 | arr = [self.ptype] 31 | for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5): 32 | if v is None: 33 | break 34 | arr.append(v) 35 | return ", ".join(arr) 36 | 37 | def __repr__(self): 38 | return ''.format(self.id, str(self)) 39 | 40 | 41 | def query_for_rule(session, adapter, ptype, v0, v1, v2): 42 | rule_filter = Filter() 43 | rule_filter.ptype = [ptype] 44 | rule_filter.v0 = [v0] 45 | rule_filter.v1 = [v1] 46 | rule_filter.v2 = [v2] 47 | query = session.query(CasbinRuleSoftDelete) 48 | query = adapter.filter_query(query, rule_filter) 49 | return query 50 | 51 | 52 | class TestConfigSoftDelete(TestConfig): 53 | def get_enforcer(self): 54 | engine = create_engine("sqlite://") 55 | # engine = create_engine("sqlite:///" + os.path.split(os.path.realpath(__file__))[0] + "/test.db",echo=True,) 56 | adapter = Adapter(engine, CasbinRuleSoftDelete, CasbinRuleSoftDelete.is_deleted) 57 | 58 | session = sessionmaker(bind=engine) 59 | Base.metadata.create_all(engine) 60 | s = session() 61 | s.query(CasbinRuleSoftDelete).delete() 62 | s.add(CasbinRuleSoftDelete(ptype="p", v0="alice", v1="data1", v2="read")) 63 | s.add(CasbinRuleSoftDelete(ptype="p", v0="bob", v1="data2", v2="write")) 64 | s.add(CasbinRuleSoftDelete(ptype="p", v0="data2_admin", v1="data2", v2="read")) 65 | s.add(CasbinRuleSoftDelete(ptype="p", v0="data2_admin", v1="data2", v2="write")) 66 | s.add(CasbinRuleSoftDelete(ptype="g", v0="alice", v1="data2_admin")) 67 | s.commit() 68 | s.close() 69 | 70 | scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) 71 | model_path = scriptdir / "rbac_model.conf" 72 | 73 | return casbin.Enforcer(str(model_path), adapter) 74 | 75 | def test_custom_db_class(self): 76 | class CustomRule(Base): 77 | __tablename__ = "casbin_rule3" 78 | __table_args__ = {"extend_existing": True} 79 | 80 | id = Column(Integer, primary_key=True) 81 | ptype = Column(String(255)) 82 | v0 = Column(String(255)) 83 | v1 = Column(String(255)) 84 | v2 = Column(String(255)) 85 | v3 = Column(String(255)) 86 | v4 = Column(String(255)) 87 | v5 = Column(String(255)) 88 | is_deleted = Column(Boolean, default=False) 89 | not_exist = Column(String(255)) 90 | 91 | engine = create_engine("sqlite://") 92 | adapter = Adapter(engine, CustomRule, CustomRule.is_deleted) 93 | 94 | session = sessionmaker(bind=engine) 95 | Base.metadata.create_all(engine) 96 | s = session() 97 | s.add(CustomRule(not_exist="NotNone")) 98 | s.commit() 99 | self.assertEqual(s.query(CustomRule).all()[0].not_exist, "NotNone") 100 | 101 | def test_softdelete_flag(self): 102 | e = self.get_enforcer() 103 | session = e.adapter.session_local() 104 | query = query_for_rule(session, e.adapter, "p", "alice", "data5", "read") 105 | 106 | self.assertFalse(e.enforce("alice", "data5", "read")) 107 | self.assertIsNone(query.first()) 108 | e.add_permission_for_user("alice", "data5", "read") 109 | self.assertTrue(e.enforce("alice", "data5", "read")) 110 | self.assertTrue(query.count() == 1) 111 | self.assertFalse(query.first().is_deleted) 112 | e.delete_permission_for_user("alice", "data5", "read") 113 | self.assertFalse(e.enforce("alice", "data5", "read")) 114 | self.assertTrue(query.count() == 1) 115 | self.assertTrue(query.first().is_deleted) 116 | 117 | def test_save_policy_softdelete(self): 118 | e = self.get_enforcer() 119 | session = e.adapter.session_local() 120 | 121 | # Turn off auto save 122 | e.enable_auto_save(auto_save=False) 123 | 124 | # Delete some preexisting rules 125 | e.delete_permission_for_user("alice", "data1", "read") 126 | e.delete_permission_for_user("bob", "data2", "write") 127 | # Delete a non existing rule 128 | e.delete_permission_for_user("bob", "data100", "read") 129 | # Add some new rules 130 | e.add_permission_for_user("alice", "data100", "read") 131 | e.add_permission_for_user("bob", "data100", "write") 132 | 133 | # Write changes to database 134 | e.save_policy() 135 | 136 | self.assertTrue( 137 | query_for_rule(session, e.adapter, "p", "alice", "data1", "read") 138 | .first() 139 | .is_deleted 140 | ) 141 | self.assertTrue( 142 | query_for_rule(session, e.adapter, "p", "bob", "data2", "write") 143 | .first() 144 | .is_deleted 145 | ) 146 | self.assertIsNone( 147 | query_for_rule(session, e.adapter, "p", "bob", "data100", "read").first() 148 | ) 149 | self.assertFalse( 150 | query_for_rule(session, e.adapter, "p", "alice", "data100", "read") 151 | .first() 152 | .is_deleted 153 | ) 154 | self.assertFalse( 155 | query_for_rule(session, e.adapter, "p", "bob", "data100", "write") 156 | .first() 157 | .is_deleted 158 | ) 159 | --------------------------------------------------------------------------------