feat: add initial code
diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5181c73 --- /dev/null +++ b/.coveragerc
@@ -0,0 +1,3 @@ +[run] +include = async_casbin_adapter/* +omit = *migrations*, *tests* \ No newline at end of file
diff --git a/.github/semantic.yml b/.github/semantic.yml new file mode 100644 index 0000000..96f9233 --- /dev/null +++ b/.github/semantic.yml
@@ -0,0 +1,2 @@ +# Always validate the PR title AND all the commits +titleAndCommits: true
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a6a3495 --- /dev/null +++ b/.github/workflows/build.yml
@@ -0,0 +1,101 @@ +name: build +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12'] + os: [ubuntu-latest, macOS-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -r requirements_dev.txt + pip install coveralls + pip install pytest + + - name: Run tests + run: coverage run -m django test --settings=tests.settings + + - name: Upload coverage data to coveralls.io + run: coveralls --service=github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.os }} - ${{ matrix.python-version }} + COVERALLS_PARALLEL: true + + lint: + name: Run Linters + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Super-Linter + uses: github/super-linter@v4.9.2 + env: + VALIDATE_ALL_CODEBASE: false + VALIDATE_PYTHON_BLACK: true + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LINTER_RULES_PATH: / + PYTHON_BLACK_CONFIG_FILE: pyproject.toml + + coveralls: + name: Indicate completion to coveralls.io + needs: test + runs-on: ubuntu-latest + container: python:3-slim + steps: + - name: Finished + run: | + pip3 install --upgrade coveralls + coveralls --finish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release: + name: Release + runs-on: ubuntu-latest + needs: [ test, coveralls ] + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '20' + + - name: Setup + run: npm install + + - name: Set up python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: npx semantic-release
diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..94e24cd --- /dev/null +++ b/.releaserc.json
@@ -0,0 +1,23 @@ +{ + "branches": "master", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "semantic-release-pypi", + "@semantic-release/github", + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md", + "changelogTitle": "# Semantic Versioning Changelog" + } + ], + [ + "@semantic-release/git", + { + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", + "assets": ["CHANGELOG.md", "pyproject.toml"] + } + ] + ] +} \ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/CHANGELOG.md
diff --git a/README.md b/README.md index d1e97c4..1510125 100644 --- a/README.md +++ b/README.md
@@ -1,2 +1,95 @@ # async-django-orm-adapter -Async Django ORM Adapter for PyCasbin + +[](https://discord.gg/S5UjpzGZjN) + +Asynchronous Django ORM Adapter is the async [Django](https://www.djangoproject.com/) [ORM](https://docs.djangoproject.com/en/3.0/ref/databases/) adapter for [PyCasbin](https://github.com/casbin/pycasbin). With this library, Casbin can load policy from Django ORM supported database or save policy to it. + +Based on [Officially Supported Databases](https://docs.djangoproject.com/en/3.0/ref/databases/), The current supported databases are: + +- PostgreSQL +- MariaDB +- MySQL +- Oracle +- SQLite +- IBM DB2 +- Microsoft SQL Server +- Firebird +- ODBC + +## Installation + +``` +pip install casbin-async-django-orm-adapter +``` + +Add `async_casbin_adapter.apps.AsyncCasbinAdapterConfig` to your `INSTALLED_APPS` + +```python +# settings.py +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +INSTALLED_APPS = [ + ... + 'async_casbin_adapter.apps.AsyncCasbinAdapterConfig', + ... +] + +CASBIN_MODEL = os.path.join(BASE_DIR, 'casbin.conf') +``` + +To run schema migration, execute `python manage.py migrate async_casbin_adapter` + +## Simple Example + +```python +# views.py +from async_casbin_adapter.enforcer import get_enforcer + +async def hello(request): + sub = "alice" # the user that wants to access a resource. + obj = "data1" # the resource that is going to be accessed. + act = "read" # the operation that the user performs on the resource. + + enforcer = await get_enforcer() + if e.enforce(sub, obj, act): + # permit alice to read data1casbin_django_orm_adapter + pass + else: + # deny the request, show an error + pass +``` + +## Configuration + +### `CASBIN_MODEL` +A string containing the file location of your casbin model. + +### `CASBIN_ADAPTER` +A string containing the adapter import path. Default to the django adapter shipped with this package: `async_casbin_adapter.adapter.AsyncAdapter` + +### `CASBIN_ADAPTER_ARGS` +A tuple of arguments to be passed into the constructor of the adapter specified +in `CASBIN_ADAPTER`. Refer to adapters to see available arguments. + +E.g. if you wish to use the file adapter +set the adapter to `casbin.persist.adapters.FileAdapter` and use +`CASBIN_ADAPTER_ARGS = ('path/to/policy_file.csv',)` + +### `CASBIN_DB_ALIAS` +The database the adapter uses. Default to "default". + +### `CASBIN_WATCHER` +Watcher instance to be set as the watcher on the enforcer instance. + +### `CASBIN_ROLE_MANAGER` +Role manager instance to be set as the role manager on the enforcer instance. + + +### Getting Help + +- [PyCasbin](https://github.com/casbin/pycasbin) + +### License + +This project is licensed under the [Apache 2.0 license](LICENSE). +
diff --git a/async_casbin_adapter/__init__.py b/async_casbin_adapter/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/async_casbin_adapter/__init__.py
diff --git a/async_casbin_adapter/adapter.py b/async_casbin_adapter/adapter.py new file mode 100644 index 0000000..f0029ba --- /dev/null +++ b/async_casbin_adapter/adapter.py
@@ -0,0 +1,100 @@ +# Copyright 2025 The casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from casbin import persist +from casbin.persist.adapters.asyncio import AsyncAdapter +from django.db.utils import OperationalError, ProgrammingError + +from .models import CasbinRule + +logger = logging.getLogger(__name__) + + +class AsyncAdapter(AsyncAdapter): + """the interface for Casbin async adapters.""" + + def __init__(self, db_alias="default"): + self.db_alias = db_alias + + async def load_policy(self, model): + """loads all policy rules from the storage.""" + try: + lines = CasbinRule.objects.using(self.db_alias).all() + + async for line in lines: + persist.load_policy_line(str(line), model) + except (OperationalError, ProgrammingError) as error: + logger.warning("Could not load policy from database: {}".format(error)) + + def _create_policy_line(self, ptype, rule): + line = CasbinRule(ptype=ptype) + if len(rule) > 0: + line.v0 = rule[0] + if len(rule) > 1: + line.v1 = rule[1] + if len(rule) > 2: + line.v2 = rule[2] + if len(rule) > 3: + line.v3 = rule[3] + if len(rule) > 4: + line.v4 = rule[4] + if len(rule) > 5: + line.v5 = rule[5] + return line + + async def save_policy(self, model): + """saves all policy rules to the storage.""" + # this will delete all rules + await CasbinRule.objects.using(self.db_alias).all().adelete() + + lines = [] + for sec in ["p", "g"]: + if sec not in model.model.keys(): + continue + for ptype, ast in model.model[sec].items(): + for rule in ast.policy: + lines.append(self._create_policy_line(ptype, rule)) + + db_alias = self.db_alias if self.db_alias else "default" + rows_created = await CasbinRule.objects.using(db_alias).abulk_create(lines) + return len(rows_created) > 0 + + async def add_policy(self, sec, ptype, rule): + """adds a policy rule to the storage.""" + line = self._create_policy_line(ptype, rule) + await line.asave() + + async def remove_policy(self, sec, ptype, rule): + """removes a policy rule from the storage.""" + query_params = {"ptype": ptype} + for i, v in enumerate(rule): + query_params["v{}".format(i)] = v + + rows_deleted, _ = await CasbinRule.objects.using(self.db_alias).filter(**query_params).adelete() + return rows_deleted > 0 + + async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): + """removes policy rules that match the filter from the storage.""" + query_params = {"ptype": ptype} + if not (0 <= field_index <= 5): + return False + if not (1 <= field_index + len(field_values) <= 6): + return False + for i, v in enumerate(field_values): + query_params["v{}".format(i + field_index)] = v + + rows_deleted, _ = await CasbinRule.objects.using(self.db_alias).filter(**query_params).adelete() + return rows_deleted > 0
diff --git a/async_casbin_adapter/apps.py b/async_casbin_adapter/apps.py new file mode 100644 index 0000000..3b1c625 --- /dev/null +++ b/async_casbin_adapter/apps.py
@@ -0,0 +1,24 @@ +# Copyright 2025 The casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.apps import AppConfig +import logging + + +logger = logging.getLogger(__name__) + + +class AsyncCasbinAdapterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "async_casbin_adapter"
diff --git a/async_casbin_adapter/enforcer.py b/async_casbin_adapter/enforcer.py new file mode 100644 index 0000000..cdcde0b --- /dev/null +++ b/async_casbin_adapter/enforcer.py
@@ -0,0 +1,81 @@ +# Copyright 2025 The casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from django.conf import settings +from django.db import connections +from asgiref.sync import sync_to_async + +from casbin import AsyncEnforcer + +from .utils import import_class + + +logger = logging.getLogger(__name__) + + +def _perform_sync_db_check(db_alias): + logger.info(f"Performing synchronous DB check for migration on alias '{db_alias}'...") + connection = connections[db_alias] + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT app, name FROM django_migrations + WHERE app = 'async_casbin_adapter' AND name = '0001_initial'; + """ + ) + row = cursor.fetchone() + + if not row: + raise RuntimeError("Async Casbin Adapter initial migration not applied. ") + logger.info("Synchronous DB check passed.") + return True + except Exception as e: + logger.error(f"Synchronous DB check failed: {e}") + raise + + +async def get_enforcer(db_alias=None): + """ + An asynchronous factory function for creating and initializing Casbin AsyncEnforcer + """ + if db_alias is None: + db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default") + + async_db_check = sync_to_async(_perform_sync_db_check, thread_sensitive=True) + await async_db_check(db_alias) + + model = getattr(settings, "CASBIN_MODEL") + adapter_loc = getattr(settings, "ASYNC_CASBIN_ADAPTER", "async_casbin_adapter.adapter.AsyncAdapter") + adapter_args = getattr(settings, "CASBIN_ADAPTER_ARGS", tuple()) + Adapter = import_class(adapter_loc) + adapter = Adapter(db_alias, *adapter_args) + + enforcer = AsyncEnforcer(model, adapter) + + watcher_loc = getattr(settings, "CASBIN_WATCHER", None) + if watcher_loc: + Watcher = import_class(watcher_loc) + enforcer.set_watcher(Watcher()) + + role_manager_loc = getattr(settings, "CASBIN_ROLE_MANAGER", None) + if role_manager_loc: + RoleManager = import_class(role_manager_loc) + enforcer.set_role_manager(RoleManager()) + + await enforcer.load_policy() + + logger.info("Casbin AsyncEnforcer initialized successfully.") + return enforcer
diff --git a/async_casbin_adapter/migrations/0001_initial.py b/async_casbin_adapter/migrations/0001_initial.py new file mode 100644 index 0000000..20d0441 --- /dev/null +++ b/async_casbin_adapter/migrations/0001_initial.py
@@ -0,0 +1,37 @@ +# Generated by Django 5.2.5 on 2025-09-05 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="CasbinRule", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("ptype", models.CharField(max_length=255)), + ("v0", models.CharField(max_length=255)), + ("v1", models.CharField(max_length=255)), + ("v2", models.CharField(max_length=255)), + ("v3", models.CharField(max_length=255)), + ("v4", models.CharField(max_length=255)), + ("v5", models.CharField(max_length=255)), + ], + options={ + "db_table": "casbin_rule", + }, + ), + ]
diff --git a/async_casbin_adapter/migrations/__init__.py b/async_casbin_adapter/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/async_casbin_adapter/migrations/__init__.py
diff --git a/async_casbin_adapter/models.py b/async_casbin_adapter/models.py new file mode 100644 index 0000000..4551c59 --- /dev/null +++ b/async_casbin_adapter/models.py
@@ -0,0 +1,48 @@ +# Copyright 2025 The casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.db import models + + +class CasbinRule(models.Model): + ptype = models.CharField(max_length=255) + v0 = models.CharField(max_length=255) + v1 = models.CharField(max_length=255) + v2 = models.CharField(max_length=255) + v3 = models.CharField(max_length=255) + v4 = models.CharField(max_length=255) + v5 = models.CharField(max_length=255) + + class Meta: + db_table = "casbin_rule" + + def __str__(self): + text = self.ptype + + if self.v0: + text = text + ", " + self.v0 + if self.v1: + text = text + ", " + self.v1 + if self.v2: + text = text + ", " + self.v2 + if self.v3: + text = text + ", " + self.v3 + if self.v4: + text = text + ", " + self.v4 + if self.v5: + text = text + ", " + self.v5 + return text + + def __repr__(self): + return '<CasbinRule {}: "{}">'.format(self.id, str(self))
diff --git a/async_casbin_adapter/utils.py b/async_casbin_adapter/utils.py new file mode 100644 index 0000000..71acf7a --- /dev/null +++ b/async_casbin_adapter/utils.py
@@ -0,0 +1,25 @@ +# Copyright 2025 The casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib + + +def import_class(name): + """Import class from string + e.g. `package.module.ClassToImport` returns the `ClassToImport` class""" + components = name.split(".") + module_name = ".".join(components[:-1]) + class_name = components[-1] + module = importlib.import_module(module_name) + return getattr(module, class_name)
diff --git a/package.json b/package.json new file mode 100644 index 0000000..16bdf0f --- /dev/null +++ b/package.json
@@ -0,0 +1,8 @@ +{ + "devDependencies": { + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "semantic-release": "^22.0.5", + "semantic-release-pypi": "^5.1.0" + } +}
diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fbdb8af --- /dev/null +++ b/pyproject.toml
@@ -0,0 +1,47 @@ +[project] +name = "casbin-async-django-orm-adapter" +version = "0.1.0" +authors = [ + {name = "Yang Luo", email = "hsluoyz@qq.com"}, +] +description = "Async Django ORM adapter for PyCasbin" +readme = "README.md" +keywords = [ + "async", + "pycasbin", + "casbin", + "adapter", + "storage-driver", + "django", + "orm", + "django-orm", + "access-control", + "authorization" +] +dynamic = ["dependencies"] +requires-python = ">=3.7" +license = {text = "Apache 2.0"} +classifiers = [ + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] + +[project.urls] +Home-page = "https://github.com/officialpycasbin/async-django-orm-adapter" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +exclude = ["tests"] + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + +[tool.black] +line-length = 120 \ No newline at end of file
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c1bf56 --- /dev/null +++ b/requirements.txt
@@ -0,0 +1,2 @@ +pycasbin>=2.0.0 +Django \ No newline at end of file
diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..d22c302 --- /dev/null +++ b/requirements_dev.txt
@@ -0,0 +1,2 @@ +-r requirements.txt +setuptools \ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py
diff --git a/tests/rbac_model.conf b/tests/rbac_model.conf new file mode 100644 index 0000000..9ca4b92 --- /dev/null +++ b/tests/rbac_model.conf
@@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
diff --git a/tests/rbac_policy.csv b/tests/rbac_policy.csv new file mode 100644 index 0000000..4150272 --- /dev/null +++ b/tests/rbac_policy.csv
@@ -0,0 +1,6 @@ +p, alice, data1, read +p, bob, data2, write +p, data2_admin, data2, read +p, data2_admin, data2, write + +g, alice, data2_admin
diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..29e2069 --- /dev/null +++ b/tests/settings.py
@@ -0,0 +1,45 @@ +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SECRET_KEY = "not-a-production-secret" + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "async_casbin_adapter.apps.AsyncCasbinAdapterConfig", + "tests", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} + +CASBIN_MODEL = os.path.join(BASE_DIR, "tests", "rbac_model.conf")
diff --git a/tests/test_adapter.py b/tests/test_adapter.py new file mode 100644 index 0000000..49aac71 --- /dev/null +++ b/tests/test_adapter.py
@@ -0,0 +1,135 @@ +import os +from casbin import AsyncEnforcer +import simpleeval + +from django.test import TestCase +from async_casbin_adapter.models import CasbinRule +from async_casbin_adapter.adapter import AsyncAdapter + + +def get_fixture(path): + dir_path = os.path.split(os.path.realpath(__file__))[0] + "/" + return os.path.abspath(dir_path + path) + + +async def get_async_enforcer(): + adapter = AsyncAdapter() + + await CasbinRule.objects.abulk_create( + [ + CasbinRule(ptype="p", v0="alice", v1="data1", v2="read"), + CasbinRule(ptype="p", v0="bob", v1="data2", v2="write"), + CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="read"), + CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="write"), + CasbinRule(ptype="g", v0="alice", v1="data2_admin"), + ] + ) + + enforcer = AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + await enforcer.load_policy() + return enforcer + + +class TestAsyncAdapter(TestCase): + async def test_enforcer_basic(self): + e = await get_async_enforcer() + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("bob", "data1", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + async def test_add_policy(self): + adapter = AsyncAdapter() + e = AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + + try: + self.assertFalse(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("bob", "data1", "read")) + self.assertFalse(e.enforce("bob", "data2", "write")) + self.assertFalse(e.enforce("alice", "data2", "read")) + self.assertFalse(e.enforce("alice", "data2", "write")) + except simpleeval.NameNotDefined: + pass + + await adapter.add_policy(sec="p", ptype="p", rule=["alice", "data1", "read"]) + await adapter.add_policy(sec="p", ptype="p", rule=["bob", "data2", "write"]) + await adapter.add_policy(sec="p", ptype="p", rule=["data2_admin", "data2", "read"]) + await adapter.add_policy(sec="p", ptype="p", rule=["data2_admin", "data2", "write"]) + await adapter.add_policy(sec="g", ptype="g", rule=["alice", "data2_admin"]) + + await e.load_policy() + + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("bob", "data1", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + self.assertFalse(e.enforce("bogus", "data2", "write")) + + async def test_save_policy(self): + enforcer_for_model = AsyncEnforcer(get_fixture("rbac_model.conf"), get_fixture("rbac_policy.csv")) + await enforcer_for_model.load_policy() + model = enforcer_for_model.model + + adapter = AsyncAdapter() + await adapter.save_policy(model) + e = AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + await e.load_policy() + + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("bob", "data1", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + async def test_autosave_off_doesnt_persist_to_db(self): + adapter = AsyncAdapter() + e = AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + + e.enable_auto_save(False) + await e.add_policy("alice", "data1", "write") + await e.load_policy() + policies = e.get_policy() + + self.assertListEqual(policies, []) + + async def test_autosave_on_persists_to_db(self): + adapter = AsyncAdapter() + e = AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + + e.enable_auto_save(True) + await e.add_policy("alice", "data1", "write") + await e.load_policy() + policies = e.get_policy() + self.assertListEqual(policies, [["alice", "data1", "write"]]) + + async def test_autosave_on_persists_remove_action_to_db(self): + adapter = AsyncAdapter() + e = AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + await e.add_policy("alice", "data1", "write") + await e.load_policy() + self.assertListEqual(e.get_policy(), [["alice", "data1", "write"]]) + + e.enable_auto_save(True) + await e.remove_policy("alice", "data1", "write") + await e.load_policy() + self.assertListEqual(e.get_policy(), []) + + async def test_remove_filtered_policy(self): + e = await get_async_enforcer() + + await e.remove_filtered_policy(0, "data2_admin") + await e.load_policy() + self.assertListEqual(e.get_policy(), [["alice", "data1", "read"], ["bob", "data2", "write"]]) + + def test_str(self): + rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") + self.assertEqual(str(rule), "p, alice, data1, read") + + async def test_repr(self): + rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") + self.assertEqual(repr(rule), '<CasbinRule None: "p, alice, data1, read">') + + await rule.asave() + self.assertRegex(repr(rule), r'<CasbinRule \d+: "p, alice, data1, read">')
diff --git a/tests/test_enforcer.py b/tests/test_enforcer.py new file mode 100644 index 0000000..22b96e3 --- /dev/null +++ b/tests/test_enforcer.py
@@ -0,0 +1,49 @@ +import os +from django.test import TestCase +from async_casbin_adapter.enforcer import get_enforcer +from async_casbin_adapter.models import CasbinRule + + +def get_fixture(path): + dir_path = os.path.split(os.path.realpath(__file__))[0] + "/" + return os.path.abspath(dir_path + path) + + +async def db_setup(): + await CasbinRule.objects.abulk_create( + [ + CasbinRule(ptype="p", v0="alice", v1="data1", v2="read"), + CasbinRule(ptype="p", v0="bob", v1="data2", v2="write"), + CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="read"), + CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="write"), + CasbinRule(ptype="g", v0="alice", v1="data2_admin"), + ] + ) + + +class TestEnforcer(TestCase): + async def test_get_enforcer_basic(self): + enforcer = await get_enforcer() + await enforcer.add_policy("alice", "data1", "read") + self.assertEqual(enforcer.get_policy(), [["alice", "data1", "read"]]) + self.assertTrue(enforcer.enforce("alice", "data1", "read")) + + async def test_get_enforcer(self): + await db_setup() + enforcer = await get_enforcer() + + self.assertEqual( + enforcer.get_policy(), + [ + ["alice", "data1", "read"], + ["bob", "data2", "write"], + ["data2_admin", "data2", "read"], + ["data2_admin", "data2", "write"], + ], + ) + self.assertTrue(enforcer.enforce("alice", "data1", "read")) + self.assertFalse(enforcer.enforce("bob", "data1", "read")) + self.assertTrue(enforcer.enforce("bob", "data2", "write")) + self.assertTrue(enforcer.enforce("alice", "data2", "read")) + self.assertTrue(enforcer.enforce("alice", "data2", "write")) + self.assertFalse(enforcer.enforce("bogus", "data2", "write"))