diff --git a/.github/semantic.yml b/.github/semantic.yml new file mode 100644 index 0000000..605f6a7 --- /dev/null +++ b/.github/semantic.yml @@ -0,0 +1,2 @@ +# Always validate the PR title AND all the commits +titleAndCommits: true \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ce87dec --- /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.9', '3.10', '3.11'] + os: [ubuntu-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: Set up Redis + uses: nnhy/redis-github-action@v1.0 + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install coveralls + + - name: Run tests + run: coverage run -m unittest discover -s tests -t tests + + - 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 + + - name: Super-Linter + uses: github/super-linter@v4.2.2 + env: + VALIDATE_PYTHON_BLACK: true + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + 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: '18' + + - name: Setup + 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 + + - name: Set up python + uses: actions/setup-python@v2 + with: + python-version: '3.10' + + - name: Install setuptools + run: python -m pip install --upgrade setuptools wheel twine + + - name: Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: npx semantic-release \ No newline at end of file diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..829f449 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,27 @@ +{ + "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", + "setup.py", + "setup.cfg" + ] + } + ] + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 218875c..1c40f2e 100644 --- a/README.md +++ b/README.md @@ -1 +1,101 @@ -# async-redis-adapter \ No newline at end of file +Async Redis Adapter for PyCasbin +==== + +[![GitHub Actions](https://github.com/pycasbin/async-redis-adapter/workflows/build/badge.svg?branch=master)](https://github.com/pycasbin/async-redis-adapter/actions) +[![Coverage Status](https://coveralls.io/repos/github/pycasbin/async-redis-adapter/badge.svg?branch=master)](https://coveralls.io/github/pycasbin/async-redis-adapter?branch=master) +[![Version](https://img.shields.io/pypi/v/casbin_async_redis_adapter.svg)](https://pypi.org/project/casbin_async_redis_adapter/) +[![PyPI - Wheel](https://img.shields.io/pypi/wheel/casbin_async_redis_adapter.svg)](https://pypi.org/project/casbin_async_redis_adapter/) +[![Pyversions](https://img.shields.io/pypi/pyversions/casbin_async_redis_adapter.svg)](https://pypi.org/project/casbin_async_redis_adapter/) +[![Download](https://img.shields.io/pypi/dm/casbin_async_redis_adapter.svg)](https://pypi.org/project/casbin_async_redis_adapter/) +[![License](https://img.shields.io/pypi/l/casbin_async_redis_adapter.svg)](https://pypi.org/project/casbin_async_redis_adapter/) + +Async Redis Adapter is the async [redis](https://redis.io/) adapter for [PyCasbin](https://github.com/casbin/pycasbin). +With this +library, Casbin can load policy from redis or save policy to it. + +## Installation + +``` +pip install casbin_async_redis_adapter +``` + +## Simple Example + +```python +import asyncio +from casbin_async_redis_adapter import Adapter +import casbin + + +async def get_enforcer(): + adapter = Adapter("localhost", 6379, encoding="utf-8") + e = casbin.AsyncEnforcer("rbac_model.conf", adapter) + model = e.get_model() + + model.clear_policy() + model.add_policy("p", "p", ["alice", "data1", "read"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("p", "p", ["bob", "data2", "write"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("p", "p", ["data2_admin", "data2", "read"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("p", "p", ["data2_admin", "data2", "write"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("g", "g", ["alice", "data2_admin"]) + await adapter.save_policy(model) + + e = casbin.AsyncEnforcer("rbac_model.conf", adapter) + await e.load_policy() + + return e + + +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. + + +async def main(): + e = await get_enforcer() + if e.enforce("alice", "data1", "read"): + print("alice can read data1") + else: + print("alice can not read data1") + + +asyncio.run(main()) +``` + +## Configuration + +`Adapter()` enable decode_responses by default and supports any Redis parameter configuration. + +To use casbin_redis_adapter, you must provide the following parameter configuration + +- `host`: address of the redis service +- `port`: redis service port + +The following parameters are provided by default + +- `db`: redis database, default is `0` +- `username`: redis username, default is `None` +- `password`: redis password, default is `None` +- `key`: casbin rule to store key, default is `casbin_rules` + +For more parameters, please follow [redis-py](https://redis.readthedocs.io/en/stable/connections.html#redis.Redis) + +### Getting Help + +- [PyCasbin](https://github.com/casbin/pycasbin) + +### License + +This project is licensed under the [Apache 2.0 license](LICENSE). \ No newline at end of file diff --git a/casbin_async_redis_adapter/__init__.py b/casbin_async_redis_adapter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/casbin_async_redis_adapter/adapter.py b/casbin_async_redis_adapter/adapter.py new file mode 100644 index 0000000..8968593 --- /dev/null +++ b/casbin_async_redis_adapter/adapter.py @@ -0,0 +1,183 @@ +from casbin import persist +import redis.asyncio as redis +import json + + +class CasbinRule: + """ + CasbinRule model + """ + + def __init__( + self, ptype=None, v0=None, v1=None, v2=None, v3=None, v4=None, v5=None + ): + self.ptype = ptype + self.v0 = v0 + self.v1 = v1 + self.v2 = v2 + self.v3 = v3 + self.v4 = v4 + self.v5 = v5 + + def dict(self): + d = {"ptype": self.ptype} + + for value in dir(self): + if ( + getattr(self, value) is not None + and value.startswith("v") + and value[1:].isnumeric() + ): + d[value] = getattr(self, value) + + return d + + def __str__(self): + return ", ".join(self.dict().values()) + + def __repr__(self): + return ''.format(str(self)) + + +class Adapter(persist.Adapter): + """the interface for Casbin adapters.""" + + def __init__( + self, + host, + port, + db=0, + username=None, + password=None, + key="casbin_rules", + **kwargs, + ): + self.key = key + self.client = redis.Redis( + host=host, + port=port, + db=db, + username=username, + password=password, + decode_responses=True, + **kwargs, + ) + + async def drop_table(self): + await self.client.delete(self.key) + + async def load_policy(self, model): + """Implementing add Interface for casbin. Load all policy rules from redis + + Args: + model (CasbinRule): CasbinRule object + """ + + length = await self.client.llen(self.key) + for i in range(length): + line = await self.client.lindex(self.key, i) + line = json.loads(line) + rule = CasbinRule(**line) + persist.load_policy_line(str(rule), model) + + async def _save_policy_line(self, ptype, rule): + line = CasbinRule(ptype=ptype) + for index, value in enumerate(rule): + setattr(line, f"v{index}", value) + await self.client.rpush(self.key, json.dumps(line.dict())) + + async def _delete_policy_lines(self, ptype, rule): + line = CasbinRule(ptype=ptype) + for index, value in enumerate(rule): + setattr(line, f"v{index}", value) + + # if rule is empty, do nothing + # else find all given rules and delete them + if len(line.dict()) == 0: + return 0 + else: + await self.client.lrem(self.key, 0, json.dumps(line.dict())) + + async def save_policy(self, model) -> bool: + """Implement add Interface for casbin. Save the policy in mongodb + + Args: + model (Class Model): Casbin Model which loads from .conf file usually. + + Returns: + bool: True if succeed + """ + 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: + await self._save_policy_line(ptype, rule) + return True + + async def add_policy(self, sec, ptype, rule): + """Add policy rules to redis + + Args: + sec (str): Section name, 'g' or 'p' + ptype (str): Policy type, 'g', 'g2', 'p', etc. + rule (CasbinRule): Casbin rule will be added + + Returns: + bool: True if succeed else False + """ + await self._save_policy_line(ptype, rule) + return True + + async def remove_policy(self, sec, ptype, rule): + """Remove policy rules in redis(rules duplicate will all be removed) + + Args: + sec (str): Section name, 'g' or 'p' + ptype (str): Policy type, 'g', 'g2', 'p', etc. + rule (CasbinRule): Casbin rule if it is exactly same as will be removed. + + Returns: + bool: True if succeed else False + """ + await self._delete_policy_lines(ptype, rule) + return True + + async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): + """Remove policy rules that match the filter from the storage. + This is part of the Auto-Save feature. + + Args: + sec (str): Section name, 'g' or 'p' + ptype (str): Policy type, 'g', 'g2', 'p', etc. + field_index (int): The policy index at which the filed_values begins filtering. Its range is [0, 5] + field_values(List[str]): A list of rules to filter policy which starts from + + Returns: + bool: True if succeed else False + """ + if not (0 <= field_index <= 5): + return False + if not (1 <= field_index + len(field_values) <= 6): + return False + + length = await self.client.llen(self.key) + for i in range(length): + line = json.loads(await self.client.lindex(self.key, i)) + if ptype != line.get("ptype"): + continue + j = 1 + is_match = False + keys = list(line.keys())[field_index : field_index + len(field_values) + 1] + for field_value in field_values: + if field_value == line[keys[j]]: + j += 1 + if j == len(field_values): + is_match = True + else: + break + if is_match: + await self.client.lset(self.key, i, "__CASBIN_DELETED__") + + await self.client.lrem(self.key, 0, "__CASBIN_DELETED__") + return True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bd169f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +casbin>=1.23.0 +redis>=5.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..808b9b9 --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +from setuptools import setup, find_packages, __version__ +from os import path + +desc_file = "README.md" + +with open(desc_file, "r") as fh: + long_description = fh.read() + +here = path.abspath(path.dirname(__file__)) +# get the dependencies and installs +with open(path.join(here, "requirements.txt"), encoding="utf-8") as f: + all_reqs = f.read().split("\n") + +install_requires = [x.strip() for x in all_reqs if "git+" not in x] + +setup( + name="casbin_async_redis_adapter", + author="BustDot", + author_email="Bust.dev@outlook.com", + description="Async Redis Adapter for PyCasbin", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/pycasbin/async-redis-adapter", + keywords=[ + "casbin", + "Redis", + "casbin-adapter", + "async", + "rbac", + "access control", + "abac", + "acl", + "permission", + ], + packages=find_packages(), + install_requires=install_requires, + python_requires=">=3.8", + license="Apache 2.0", + classifiers=[ + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rbac_model.conf b/tests/rbac_model.conf new file mode 100644 index 0000000..71159e3 --- /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 \ No newline at end of file diff --git a/tests/rbac_policy.csv b/tests/rbac_policy.csv new file mode 100644 index 0000000..9bbfa7c --- /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 \ No newline at end of file diff --git a/tests/rbac_with_resources_roles.conf b/tests/rbac_with_resources_roles.conf new file mode 100644 index 0000000..845bc6c --- /dev/null +++ b/tests/rbac_with_resources_roles.conf @@ -0,0 +1,15 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ +g2 = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && g2(r.obj, p.obj) && r.act == p.act \ No newline at end of file diff --git a/tests/test_adapter.py b/tests/test_adapter.py new file mode 100644 index 0000000..974c383 --- /dev/null +++ b/tests/test_adapter.py @@ -0,0 +1,237 @@ +from casbin_async_redis_adapter.adapter import Adapter, CasbinRule + +from unittest import IsolatedAsyncioTestCase +import redis +import casbin +import os + + +def get_fixture(path): + """ + get model path + """ + dir_path = os.path.split(os.path.realpath(__file__))[0] + "/" + return os.path.abspath(dir_path + path) + + +async def get_enforcer(): + adapter = Adapter("localhost", 6379, encoding="utf-8") + e = casbin.AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + model = e.get_model() + + model.clear_policy() + model.add_policy("p", "p", ["alice", "data1", "read"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("p", "p", ["bob", "data2", "write"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("p", "p", ["data2_admin", "data2", "read"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("p", "p", ["data2_admin", "data2", "write"]) + await adapter.save_policy(model) + + model.clear_policy() + model.add_policy("g", "g", ["alice", "data2_admin"]) + await adapter.save_policy(model) + + e = casbin.AsyncEnforcer(get_fixture("rbac_model.conf"), adapter) + await e.load_policy() + + return e + + +def clear_db(dbname): + client = redis.Redis() + client.delete(dbname) + + +class TestConfig(IsolatedAsyncioTestCase): + """ + unittest + """ + + def setUp(self): + clear_db("casbin_rules") + + def tearDown(self): + clear_db("casbin_rules") + + async def test_enforcer_basic(self): + """ + test policy + """ + e = await get_enforcer() + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data2", "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): + """ + test add_policy + """ + e = await get_enforcer() + adapter = e.get_adapter() + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + # test add_policy after insert 2 rules + await adapter.add_policy(sec="p", ptype="p", rule=("alice", "data1", "write")) + await adapter.add_policy(sec="p", ptype="p", rule=("bob", "data2", "read")) + + # reload policies from database + await e.load_policy() + + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertTrue(e.enforce("alice", "data1", "write")) + self.assertTrue(e.enforce("bob", "data2", "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_remove_policy(self): + """ + test remove_policy + """ + e = await get_enforcer() + adapter = e.get_adapter() + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + # test remove_policy after delete a role definition + result = await adapter.remove_policy( + sec="g", ptype="g", rule=("alice", "data2_admin") + ) + + # reload policies from database + await e.load_policy() + + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertFalse(e.enforce("alice", "data2", "read")) + self.assertFalse(e.enforce("alice", "data2", "write")) + self.assertTrue(result) + + async def test_remove_policy_no_remove_when_rule_is_incomplete(self): + adapter = Adapter("localhost", 6379) + e = casbin.AsyncEnforcer(get_fixture("rbac_with_resources_roles.conf"), adapter) + + await adapter.add_policy(sec="p", ptype="p", rule=("alice", "data1", "write")) + await adapter.add_policy(sec="p", ptype="p", rule=("alice", "data1", "read")) + await adapter.add_policy(sec="p", ptype="p", rule=("bob", "data2", "read")) + await adapter.add_policy( + sec="p", ptype="p", rule=("data_group_admin", "data_group", "write") + ) + await adapter.add_policy(sec="g", ptype="g", rule=("alice", "data_group_admin")) + await adapter.add_policy(sec="g", ptype="g2", rule=("data2", "data_group")) + + await e.load_policy() + + self.assertTrue(e.enforce("alice", "data1", "write")) + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertTrue(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + # test remove_policy doesn't remove when given an incomplete policy + await adapter.remove_policy(sec="p", ptype="p", rule=("alice", "data1")) + await e.load_policy() + + self.assertTrue(e.enforce("alice", "data1", "write")) + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertTrue(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + async def test_save_policy(self): + """ + test save_policy + """ + + e = await get_enforcer() + self.assertFalse(e.enforce("alice", "data4", "read")) + + model = e.get_model() + model.clear_policy() + + model.add_policy("p", "p", ("alice", "data4", "read")) + + adapter = e.get_adapter() + await adapter.save_policy(model) + + self.assertTrue(e.enforce("alice", "data4", "read")) + + async def test_remove_filtered_policy(self): + """ + test remove_filtered_policy + """ + e = await get_enforcer() + adapter = e.get_adapter() + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("alice", "data2", "write")) + + result = await adapter.remove_filtered_policy( + "g", "g", 6, "alice", "data2_admin" + ) + await e.load_policy() + self.assertFalse(result) + + result = await adapter.remove_filtered_policy( + "g", "g", 0, *[f"v{i}" for i in range(7)] + ) + await e.load_policy() + self.assertFalse(result) + + result = await adapter.remove_filtered_policy( + "g", "g", 0, "alice", "data2_admin" + ) + await e.load_policy() + self.assertTrue(result) + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + self.assertFalse(e.enforce("bob", "data2", "read")) + self.assertTrue(e.enforce("bob", "data2", "write")) + self.assertFalse(e.enforce("alice", "data2", "read")) + self.assertFalse(e.enforce("alice", "data2", "write")) + + def test_str(self): + """ + test __str__ function + """ + rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") + self.assertEqual(rule.__str__(), "p, alice, data1, read") + + def test_dict(self): + """ + test dict function + """ + rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") + self.assertEqual( + rule.dict(), {"ptype": "p", "v0": "alice", "v1": "data1", "v2": "read"} + ) + + def test_repr(self): + """ + test __repr__ function + """ + rule = CasbinRule(ptype="p", v0="alice", v1="data1", v2="read") + self.assertEqual(repr(rule), '')