From 7e56335a91ebce2ca939656e838c4d2cbf83524c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:03:52 +0000 Subject: [PATCH 1/5] Initial plan From b486655585464e9e708302483ff8a570a62bd67e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:11:07 +0000 Subject: [PATCH 2/5] Add soft deletion support to async-sqlalchemy-adapter Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com> --- casbin_async_sqlalchemy_adapter/adapter.py | 153 +++++++-- tests/test_adapter_softdelete.py | 349 +++++++++++++++++++++ 2 files changed, 477 insertions(+), 25 deletions(-) create mode 100644 tests/test_adapter_softdelete.py diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index daff6bb..4c38ef8 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -16,8 +16,8 @@ from casbin import persist from casbin.persist.adapters.asyncio import AsyncAdapter -from sqlalchemy import Column, Integer, String, delete, insert -from sqlalchemy import or_ +from sqlalchemy import Column, Integer, String, Boolean, delete, insert +from sqlalchemy import or_, not_ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import declarative_base, sessionmaker @@ -66,6 +66,7 @@ def __init__( self, engine, db_class=None, + db_class_softdelete_attribute=None, filtered=False, db_session: Optional[AsyncSession] = None, ): @@ -74,9 +75,20 @@ def __init__( else: self._engine = engine + self.softdelete_attribute = None + if db_class is None: db_class = CasbinRule else: + if db_class_softdelete_attribute is not None and not isinstance( + db_class_softdelete_attribute.type, Boolean + ): + msg = f"The type of db_class_softdelete_attribute needs to be {str(Boolean)!r}. " + msg += f"An attribute of type {str(type(db_class_softdelete_attribute.type))!r} was given." + raise ValueError(msg) + # Softdelete is only supported when using custom class + self.softdelete_attribute = db_class_softdelete_attribute + for attr in ( "id", "ptype", @@ -121,7 +133,9 @@ async def create_table(self): async def load_policy(self, model): """loads all policy rules from the storage.""" async with self._session_scope() as session: - lines = await session.execute(select(self._db_class)) + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + lines = await session.execute(stmt) for line in lines.scalars(): persist.load_policy_line(str(line), model) @@ -132,6 +146,7 @@ async def load_filtered_policy(self, model, filter) -> None: """loads all policy rules from the storage.""" async with self._session_scope() as session: stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) stmt = self.filter_query(stmt, filter) result = await session.execute(stmt) for line in result.scalars(): @@ -144,6 +159,12 @@ def filter_query(self, stmt, filter): stmt = stmt.where(getattr(self._db_class, attr).in_(getattr(filter, attr))) return stmt.order_by(self._db_class.id) + def _softdelete_query(self, stmt): + """Filter out soft-deleted records if soft delete is enabled.""" + if self.softdelete_attribute is not None: + stmt = stmt.where(not_(self.softdelete_attribute)) + return stmt + async def _save_policy_line(self, ptype, rule, session=None): if session is not None: # Use provided session @@ -161,15 +182,62 @@ async def _save_policy_line(self, ptype, rule, session=None): async def save_policy(self, model): """saves all policy rules to the storage.""" + # Use the default strategy when soft delete is not enabled + if self.softdelete_attribute is None: + async with self._session_scope() as session: + stmt = delete(self._db_class) + await session.execute(stmt) + 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, session) + return True + + # Custom strategy for softdelete since it does not make sense to recreate all of the + # entries when using soft delete async with self._session_scope() as session: - stmt = delete(self._db_class) - await session.execute(stmt) + stmt = select(self._db_class) + stmt = self._softdelete_query(stmt) + + # Get entries that are not part of the model anymore + result = await session.execute(stmt) + lines_before_changes = result.scalars().all() + + # Create new entries in the database 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, session) + # Filter for rule in the database + filter_stmt = select(self._db_class).where(self._db_class.ptype == ptype) + filter_stmt = self._softdelete_query(filter_stmt) + for index, value in enumerate(rule): + v_value = getattr(self._db_class, "v{}".format(index)) + filter_stmt = filter_stmt.where(v_value == value) + # If the rule is not present, create an entry in the database + result = await session.execute(filter_stmt) + if result.scalar_one_or_none() is None: + await self._save_policy_line(ptype, rule, session=session) + + for line in lines_before_changes: + ptype = line.ptype + sec = ptype[0] # derived from persist.load_policy_line function + fields_with_None = [ + line.v0, + line.v1, + line.v2, + line.v3, + line.v4, + line.v5, + ] + rule = [element for element in fields_with_None if element is not None] + # If the rule is not part of the model, set the deletion flag to True + if not model.has_policy(sec, ptype, rule): + setattr(line, self.softdelete_attribute.name, True) + return True async def add_policy(self, sec, ptype, rule): @@ -196,42 +264,75 @@ async def add_policies(self, sec, ptype, rules): async def remove_policy(self, sec, ptype, rule): """removes a policy rule from the storage.""" async with self._session_scope() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - for i, v in enumerate(rule): - stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) - r = await session.execute(stmt) - - return True if r.rowcount > 0 else False + if self.softdelete_attribute is None: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) + for i, v in enumerate(rule): + stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) + r = await session.execute(stmt) + return True if r.rowcount > 0 else False + else: + stmt = select(self._db_class).where(self._db_class.ptype == ptype) + stmt = self._softdelete_query(stmt) + for i, v in enumerate(rule): + stmt = stmt.where(getattr(self._db_class, "v{}".format(i)) == v) + result = await session.execute(stmt) + lines = result.scalars().all() + for line in lines: + setattr(line, self.softdelete_attribute.name, True) + return True if len(lines) > 0 else False async def remove_policies(self, sec, ptype, rules): """remove policy rules from the storage.""" if not rules: return async with self._session_scope() as session: - stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - rules = zip(*rules) - for i, rule in enumerate(rules): - stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) - await session.execute(stmt) + if self.softdelete_attribute is None: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) + rules = zip(*rules) + for i, rule in enumerate(rules): + stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) + await session.execute(stmt) + else: + stmt = select(self._db_class).where(self._db_class.ptype == ptype) + stmt = self._softdelete_query(stmt) + rules_zipped = zip(*rules) + for i, rule in enumerate(rules_zipped): + stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) + result = await session.execute(stmt) + lines = result.scalars().all() + for line in lines: + setattr(line, self.softdelete_attribute.name, True) async def remove_filtered_policy(self, sec, ptype, field_index, *field_values): """removes policy rules that match the filter from the storage. This is part of the Auto-Save feature. """ async with self._session_scope() as session: - stmt = delete(self._db_class).where(self._db_class.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): - if v != "": - v_value = getattr(self._db_class, "v{}".format(field_index + i)) - stmt = stmt.where(v_value == v) - r = await session.execute(stmt) - return True if r.rowcount > 0 else False + if self.softdelete_attribute is None: + stmt = delete(self._db_class).where(self._db_class.ptype == ptype) + for i, v in enumerate(field_values): + if v != "": + v_value = getattr(self._db_class, "v{}".format(field_index + i)) + stmt = stmt.where(v_value == v) + r = await session.execute(stmt) + return True if r.rowcount > 0 else False + else: + stmt = select(self._db_class).where(self._db_class.ptype == ptype) + stmt = self._softdelete_query(stmt) + for i, v in enumerate(field_values): + if v != "": + v_value = getattr(self._db_class, "v{}".format(field_index + i)) + stmt = stmt.where(v_value == v) + result = await session.execute(stmt) + lines = result.scalars().all() + for line in lines: + setattr(line, self.softdelete_attribute.name, True) + return True if len(lines) > 0 else False async def update_policy(self, sec: str, ptype: str, old_rule: List[str], new_rule: List[str]) -> None: """ @@ -247,6 +348,7 @@ async def update_policy(self, sec: str, ptype: str, old_rule: List[str], new_rul async with self._session_scope() as session: stmt = select(self._db_class).where(self._db_class.ptype == ptype) + stmt = self._softdelete_query(stmt) # locate the old rule for index, value in enumerate(old_rule): @@ -307,6 +409,7 @@ async def _update_filtered_policies(self, new_rules, filter) -> List[List[str]]: # Load old policies stmt = select(self._db_class).where(self._db_class.ptype == filter.ptype) + stmt = self._softdelete_query(stmt) filtered_stmt = self.filter_query(stmt, filter) result = await session.execute(filtered_stmt) old_rules = result.scalars().all() diff --git a/tests/test_adapter_softdelete.py b/tests/test_adapter_softdelete.py new file mode 100644 index 0000000..e03db8b --- /dev/null +++ b/tests/test_adapter_softdelete.py @@ -0,0 +1,349 @@ +# Copyright 2023 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 os +from pathlib import Path +from unittest import IsolatedAsyncioTestCase + +import casbin +from sqlalchemy import Column, Boolean, Integer, String, select +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +from casbin_async_sqlalchemy_adapter import Adapter +from casbin_async_sqlalchemy_adapter import Base +from casbin_async_sqlalchemy_adapter.adapter import Filter + + +class CasbinRuleSoftDelete(Base): + __tablename__ = "casbin_rule_soft_delete" + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + + is_deleted = Column(Boolean, default=False, index=True, nullable=False) + + def __str__(self): + arr = [self.ptype] + for v in (self.v0, self.v1, self.v2, self.v3, self.v4, self.v5): + if v is None: + break + arr.append(v) + return ", ".join(arr) + + def __repr__(self): + return ''.format(self.id, str(self)) + + +async def query_for_rule(session, adapter, ptype, v0, v1, v2): + """Helper function to query for a specific rule.""" + rule_filter = Filter() + rule_filter.ptype = [ptype] + rule_filter.v0 = [v0] + rule_filter.v1 = [v1] + rule_filter.v2 = [v2] + stmt = select(CasbinRuleSoftDelete) + stmt = adapter.filter_query(stmt, rule_filter) + result = await session.execute(stmt) + return result.scalars().first() + + +def get_fixture(path): + dir_path = os.path.split(os.path.realpath(__file__))[0] + "/" + return os.path.abspath(dir_path + path) + + +class TestConfigSoftDelete(IsolatedAsyncioTestCase): + async def get_enforcer(self): + engine = create_async_engine("sqlite+aiosqlite://", future=True) + adapter = Adapter(engine, CasbinRuleSoftDelete, CasbinRuleSoftDelete.is_deleted) + await adapter.create_table() + + async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with async_session() as s: + s.add(CasbinRuleSoftDelete(ptype="p", v0="alice", v1="data1", v2="read")) + s.add(CasbinRuleSoftDelete(ptype="p", v0="bob", v1="data2", v2="write")) + s.add(CasbinRuleSoftDelete(ptype="p", v0="data2_admin", v1="data2", v2="read")) + s.add(CasbinRuleSoftDelete(ptype="p", v0="data2_admin", v1="data2", v2="write")) + s.add(CasbinRuleSoftDelete(ptype="g", v0="alice", v1="data2_admin")) + await s.commit() + + scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) + model_path = scriptdir / "rbac_model.conf" + + e = casbin.AsyncEnforcer(str(model_path), adapter) + await e.load_policy() + return e + + async def test_custom_db_class(self): + """Test that custom database class with softdelete works.""" + class CustomRule(Base): + __tablename__ = "casbin_rule3" + __table_args__ = {"extend_existing": True} + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + is_deleted = Column(Boolean, default=False) + not_exist = Column(String(255)) + + engine = create_async_engine("sqlite+aiosqlite://", future=True) + adapter = Adapter(engine, CustomRule, CustomRule.is_deleted) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + async with session() as s: + s.add(CustomRule(not_exist="NotNone")) + await s.commit() + a = await s.execute(select(CustomRule)) + self.assertEqual(a.scalars().all()[0].not_exist, "NotNone") + + async def test_softdelete_flag(self): + """Test that softdelete flag is set correctly when removing policies.""" + e = await self.get_enforcer() + session_maker = e.adapter.session_local + + async with session_maker() as session: + # Verify rule does not exist initially + self.assertFalse(e.enforce("alice", "data5", "read")) + rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read") + self.assertIsNone(rule) + + # Add new permission + await e.add_permission_for_user("alice", "data5", "read") + self.assertTrue(e.enforce("alice", "data5", "read")) + + async with session_maker() as session: + rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read") + self.assertIsNotNone(rule) + self.assertFalse(rule.is_deleted) + + # Delete permission - should soft delete + await e.delete_permission_for_user("alice", "data5", "read") + self.assertFalse(e.enforce("alice", "data5", "read")) + + async with session_maker() as session: + rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read") + self.assertIsNotNone(rule) + self.assertTrue(rule.is_deleted) + + async def test_save_policy_softdelete(self): + """Test that save_policy correctly marks rules as deleted.""" + e = await self.get_enforcer() + session_maker = e.adapter.session_local + + # Turn off auto save + e.enable_auto_save(auto_save=False) + + # Delete some preexisting rules using model's internal methods + e.get_model().remove_policy("p", "p", ["alice", "data1", "read"]) + e.get_model().remove_policy("p", "p", ["bob", "data2", "write"]) + # Delete a non existing rule (won't do anything in model) + e.get_model().remove_policy("p", "p", ["bob", "data100", "read"]) + # Add some new rules using model's internal methods + e.get_model().add_policy("p", "p", ["alice", "data100", "read"]) + e.get_model().add_policy("p", "p", ["bob", "data100", "write"]) + + # Write changes to database + await e.save_policy() + + async with session_maker() as session: + # Check deleted rules are marked as deleted + rule1 = await query_for_rule(session, e.adapter, "p", "alice", "data1", "read") + self.assertTrue(rule1.is_deleted) + + rule2 = await query_for_rule(session, e.adapter, "p", "bob", "data2", "write") + self.assertTrue(rule2.is_deleted) + + # Non-existent rule should not be in DB + rule3 = await query_for_rule(session, e.adapter, "p", "bob", "data100", "read") + self.assertIsNone(rule3) + + # New rules should not be deleted + rule4 = await query_for_rule(session, e.adapter, "p", "alice", "data100", "read") + self.assertIsNotNone(rule4) + self.assertFalse(rule4.is_deleted) + + rule5 = await query_for_rule(session, e.adapter, "p", "bob", "data100", "write") + self.assertIsNotNone(rule5) + self.assertFalse(rule5.is_deleted) + + async def test_softdelete_type_validation(self): + """Test that non-Boolean softdelete attribute raises ValueError.""" + class InvalidRule(Base): + __tablename__ = "invalid_rule" + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + is_deleted = Column(String(255)) # Wrong type! + + engine = create_async_engine("sqlite+aiosqlite://", future=True) + + with self.assertRaises(ValueError) as context: + Adapter(engine, InvalidRule, InvalidRule.is_deleted) + + self.assertIn("Boolean", str(context.exception)) + + async def test_remove_policies_with_softdelete(self): + """Test that remove_policies correctly soft-deletes multiple rules.""" + e = await self.get_enforcer() + session_maker = e.adapter.session_local + + # Add multiple policies + await e.add_policies([ + ["alice", "data10", "read"], + ["bob", "data10", "write"], + ["carol", "data10", "read"] + ]) + + # Verify they exist + self.assertTrue(e.enforce("alice", "data10", "read")) + self.assertTrue(e.enforce("bob", "data10", "write")) + self.assertTrue(e.enforce("carol", "data10", "read")) + + # Remove multiple policies + await e.remove_policies([ + ["alice", "data10", "read"], + ["bob", "data10", "write"] + ]) + + # Verify they are soft-deleted + self.assertFalse(e.enforce("alice", "data10", "read")) + self.assertFalse(e.enforce("bob", "data10", "write")) + self.assertTrue(e.enforce("carol", "data10", "read")) + + async with session_maker() as session: + rule1 = await query_for_rule(session, e.adapter, "p", "alice", "data10", "read") + self.assertIsNotNone(rule1) + self.assertTrue(rule1.is_deleted) + + rule2 = await query_for_rule(session, e.adapter, "p", "bob", "data10", "write") + self.assertIsNotNone(rule2) + self.assertTrue(rule2.is_deleted) + + rule3 = await query_for_rule(session, e.adapter, "p", "carol", "data10", "read") + self.assertIsNotNone(rule3) + self.assertFalse(rule3.is_deleted) + + async def test_remove_filtered_policy_with_softdelete(self): + """Test that remove_filtered_policy correctly soft-deletes matching rules.""" + e = await self.get_enforcer() + session_maker = e.adapter.session_local + + # Initial state verification + self.assertTrue(e.enforce("alice", "data2", "read")) + self.assertTrue(e.enforce("data2_admin", "data2", "read")) + + # Remove all policies for data2 (field_index=1, value="data2") + await e.remove_filtered_policy(1, "data2") + + # Verify policies are removed from enforcer + self.assertFalse(e.enforce("alice", "data2", "read")) + self.assertFalse(e.enforce("data2_admin", "data2", "read")) + + async with session_maker() as session: + # All data2 policies should be soft-deleted + rule1 = await query_for_rule(session, e.adapter, "p", "data2_admin", "data2", "read") + self.assertIsNotNone(rule1) + self.assertTrue(rule1.is_deleted) + + rule2 = await query_for_rule(session, e.adapter, "p", "data2_admin", "data2", "write") + self.assertIsNotNone(rule2) + self.assertTrue(rule2.is_deleted) + + async def test_update_policy_with_softdelete(self): + """Test that update_policy works correctly with soft delete.""" + e = await self.get_enforcer() + session_maker = e.adapter.session_local + + # Verify initial policy + self.assertTrue(e.enforce("alice", "data1", "read")) + self.assertFalse(e.enforce("alice", "data1", "write")) + + # Update policy + await e.update_policy(["alice", "data1", "read"], ["alice", "data1", "write"]) + + # Verify updated policy + self.assertFalse(e.enforce("alice", "data1", "read")) + self.assertTrue(e.enforce("alice", "data1", "write")) + + async with session_maker() as session: + # The updated rule should not be deleted + rule = await query_for_rule(session, e.adapter, "p", "alice", "data1", "write") + self.assertIsNotNone(rule) + self.assertFalse(rule.is_deleted) + + async def test_load_policy_ignores_soft_deleted(self): + """Test that load_policy ignores soft-deleted rules.""" + e = await self.get_enforcer() + session_maker = e.adapter.session_local + + # Delete a policy + await e.delete_permission_for_user("alice", "data1", "read") + + async with session_maker() as session: + rule = await query_for_rule(session, e.adapter, "p", "alice", "data1", "read") + self.assertIsNotNone(rule) + self.assertTrue(rule.is_deleted) + + # Create a new enforcer and load policy + scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) + model_path = scriptdir / "rbac_model.conf" + e2 = casbin.AsyncEnforcer(str(model_path), e.adapter) + await e2.load_policy() + + # The soft-deleted policy should not be loaded + self.assertFalse(e2.enforce("alice", "data1", "read")) + # Other policies should still be loaded + self.assertTrue(e2.enforce("bob", "data2", "write")) + + async def test_load_filtered_policy_ignores_soft_deleted(self): + """Test that load_filtered_policy ignores soft-deleted rules.""" + e = await self.get_enforcer() + + # Delete a policy + await e.delete_permission_for_user("bob", "data2", "write") + + # Create filter for data2 + filter = Filter() + filter.v1 = ["data2"] + + # Create new enforcer with filtered policy + scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) + model_path = scriptdir / "rbac_model.conf" + e2 = casbin.AsyncEnforcer(str(model_path), e.adapter) + await e2.load_filtered_policy(filter) + + # Soft-deleted policy should not be loaded + self.assertFalse(e2.enforce("bob", "data2", "write")) + # Other data2 policies should be loaded + self.assertTrue(e2.enforce("data2_admin", "data2", "read")) From 9f3174c871b059e3cbc5d42319c48f337056e17a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:11:37 +0000 Subject: [PATCH 3/5] Add soft deletion documentation to README Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com> --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/README.md b/README.md index 2c89798..498c3fe 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,75 @@ async with async_session() as session: await session.commit() ``` +## Soft Deletion Support + +The adapter supports soft deletion, which marks records as deleted instead of physically removing them from the database. This is useful for: + +- Maintaining audit trails +- Implementing undo functionality +- Preserving historical data +- Debugging and compliance requirements + +### Basic Usage with Soft Deletion + +To enable soft deletion, you need to: + +1. Create a custom database model with a boolean `is_deleted` column +2. Pass the soft delete attribute to the adapter + +```python +import casbin_async_sqlalchemy_adapter +import casbin +from sqlalchemy import Column, Boolean, Integer, String +from sqlalchemy.ext.asyncio import create_async_engine + +# Define a custom model with soft delete support +class CasbinRuleSoftDelete(casbin_async_sqlalchemy_adapter.Base): + __tablename__ = "casbin_rule" + + id = Column(Integer, primary_key=True) + ptype = Column(String(255)) + v0 = Column(String(255)) + v1 = Column(String(255)) + v2 = Column(String(255)) + v3 = Column(String(255)) + v4 = Column(String(255)) + v5 = Column(String(255)) + + # Add the soft delete column + is_deleted = Column(Boolean, default=False, index=True, nullable=False) + +# Create adapter with soft delete support +engine = create_async_engine('sqlite+aiosqlite:///test.db') +adapter = casbin_async_sqlalchemy_adapter.Adapter( + engine, + db_class=CasbinRuleSoftDelete, + db_class_softdelete_attribute=CasbinRuleSoftDelete.is_deleted +) + +# Create the table +await adapter.create_table() + +e = casbin.AsyncEnforcer('path/to/model.conf', adapter) + +# When you delete a policy, it will be soft-deleted (marked as deleted) +await e.delete_permission_for_user("alice", "data1", "read") + +# The record remains in the database with is_deleted=True +# Load policy will automatically filter out soft-deleted records +await e.load_policy() +``` + +### How Soft Deletion Works + +When soft deletion is enabled: + +- **Delete operations** set the `is_deleted` flag to `True` instead of removing records +- **Load operations** automatically filter out records where `is_deleted=True` +- **Save policy** marks removed rules as deleted while preserving the records +- **Update operations** only affect non-deleted records + +This feature maintains full backward compatibility - when `db_class_softdelete_attribute` is not provided, the adapter functions with hard deletion as before. ### Getting Help From 870da305ef95e612cd231da4f23667337d7ba533 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:16:34 +0000 Subject: [PATCH 4/5] Fix code review issues: convert DB objects to rule lists and improve variable naming Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com> --- casbin_async_sqlalchemy_adapter/adapter.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index 4c38ef8..11c59ee 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -288,8 +288,8 @@ async def remove_policies(self, sec, ptype, rules): async with self._session_scope() as session: if self.softdelete_attribute is None: stmt = delete(self._db_class).where(self._db_class.ptype == ptype) - rules = zip(*rules) - for i, rule in enumerate(rules): + rules_zipped = zip(*rules) + for i, rule in enumerate(rules_zipped): stmt = stmt.where(or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)) await session.execute(stmt) else: @@ -412,7 +412,21 @@ async def _update_filtered_policies(self, new_rules, filter) -> List[List[str]]: stmt = self._softdelete_query(stmt) filtered_stmt = self.filter_query(stmt, filter) result = await session.execute(filtered_stmt) - old_rules = result.scalars().all() + old_rules_db = result.scalars().all() + + # Convert database objects to rule lists + old_rules = [] + for line in old_rules_db: + fields_with_None = [ + line.v0, + line.v1, + line.v2, + line.v3, + line.v4, + line.v5, + ] + rule = [element for element in fields_with_None if element is not None] + old_rules.append(rule) # Delete old policies From 0047426eb63f86f8026fa6d122447a670a8afdac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 07:45:07 +0000 Subject: [PATCH 5/5] Fix Black formatter issues in adapter.py and test_adapter_softdelete.py Co-authored-by: nomeguy <85475922+nomeguy@users.noreply.github.com> --- casbin_async_sqlalchemy_adapter/adapter.py | 4 +-- tests/test_adapter_softdelete.py | 41 ++++++++++------------ 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/casbin_async_sqlalchemy_adapter/adapter.py b/casbin_async_sqlalchemy_adapter/adapter.py index 11c59ee..4af3e7c 100644 --- a/casbin_async_sqlalchemy_adapter/adapter.py +++ b/casbin_async_sqlalchemy_adapter/adapter.py @@ -80,9 +80,7 @@ def __init__( if db_class is None: db_class = CasbinRule else: - if db_class_softdelete_attribute is not None and not isinstance( - db_class_softdelete_attribute.type, Boolean - ): + if db_class_softdelete_attribute is not None and not isinstance(db_class_softdelete_attribute.type, Boolean): msg = f"The type of db_class_softdelete_attribute needs to be {str(Boolean)!r}. " msg += f"An attribute of type {str(type(db_class_softdelete_attribute.type))!r} was given." raise ValueError(msg) diff --git a/tests/test_adapter_softdelete.py b/tests/test_adapter_softdelete.py index e03db8b..1f3a2b8 100644 --- a/tests/test_adapter_softdelete.py +++ b/tests/test_adapter_softdelete.py @@ -93,6 +93,7 @@ async def get_enforcer(self): async def test_custom_db_class(self): """Test that custom database class with softdelete works.""" + class CustomRule(Base): __tablename__ = "casbin_rule3" __table_args__ = {"extend_existing": True} @@ -125,7 +126,7 @@ async def test_softdelete_flag(self): """Test that softdelete flag is set correctly when removing policies.""" e = await self.get_enforcer() session_maker = e.adapter.session_local - + async with session_maker() as session: # Verify rule does not exist initially self.assertFalse(e.enforce("alice", "data5", "read")) @@ -135,7 +136,7 @@ async def test_softdelete_flag(self): # Add new permission await e.add_permission_for_user("alice", "data5", "read") self.assertTrue(e.enforce("alice", "data5", "read")) - + async with session_maker() as session: rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read") self.assertIsNotNone(rule) @@ -144,7 +145,7 @@ async def test_softdelete_flag(self): # Delete permission - should soft delete await e.delete_permission_for_user("alice", "data5", "read") self.assertFalse(e.enforce("alice", "data5", "read")) - + async with session_maker() as session: rule = await query_for_rule(session, e.adapter, "p", "alice", "data5", "read") self.assertIsNotNone(rule) @@ -174,25 +175,26 @@ async def test_save_policy_softdelete(self): # Check deleted rules are marked as deleted rule1 = await query_for_rule(session, e.adapter, "p", "alice", "data1", "read") self.assertTrue(rule1.is_deleted) - + rule2 = await query_for_rule(session, e.adapter, "p", "bob", "data2", "write") self.assertTrue(rule2.is_deleted) - + # Non-existent rule should not be in DB rule3 = await query_for_rule(session, e.adapter, "p", "bob", "data100", "read") self.assertIsNone(rule3) - + # New rules should not be deleted rule4 = await query_for_rule(session, e.adapter, "p", "alice", "data100", "read") self.assertIsNotNone(rule4) self.assertFalse(rule4.is_deleted) - + rule5 = await query_for_rule(session, e.adapter, "p", "bob", "data100", "write") self.assertIsNotNone(rule5) self.assertFalse(rule5.is_deleted) async def test_softdelete_type_validation(self): """Test that non-Boolean softdelete attribute raises ValueError.""" + class InvalidRule(Base): __tablename__ = "invalid_rule" @@ -207,10 +209,10 @@ class InvalidRule(Base): is_deleted = Column(String(255)) # Wrong type! engine = create_async_engine("sqlite+aiosqlite://", future=True) - + with self.assertRaises(ValueError) as context: Adapter(engine, InvalidRule, InvalidRule.is_deleted) - + self.assertIn("Boolean", str(context.exception)) async def test_remove_policies_with_softdelete(self): @@ -219,11 +221,7 @@ async def test_remove_policies_with_softdelete(self): session_maker = e.adapter.session_local # Add multiple policies - await e.add_policies([ - ["alice", "data10", "read"], - ["bob", "data10", "write"], - ["carol", "data10", "read"] - ]) + await e.add_policies([["alice", "data10", "read"], ["bob", "data10", "write"], ["carol", "data10", "read"]]) # Verify they exist self.assertTrue(e.enforce("alice", "data10", "read")) @@ -231,10 +229,7 @@ async def test_remove_policies_with_softdelete(self): self.assertTrue(e.enforce("carol", "data10", "read")) # Remove multiple policies - await e.remove_policies([ - ["alice", "data10", "read"], - ["bob", "data10", "write"] - ]) + await e.remove_policies([["alice", "data10", "read"], ["bob", "data10", "write"]]) # Verify they are soft-deleted self.assertFalse(e.enforce("alice", "data10", "read")) @@ -245,11 +240,11 @@ async def test_remove_policies_with_softdelete(self): rule1 = await query_for_rule(session, e.adapter, "p", "alice", "data10", "read") self.assertIsNotNone(rule1) self.assertTrue(rule1.is_deleted) - + rule2 = await query_for_rule(session, e.adapter, "p", "bob", "data10", "write") self.assertIsNotNone(rule2) self.assertTrue(rule2.is_deleted) - + rule3 = await query_for_rule(session, e.adapter, "p", "carol", "data10", "read") self.assertIsNotNone(rule3) self.assertFalse(rule3.is_deleted) @@ -275,7 +270,7 @@ async def test_remove_filtered_policy_with_softdelete(self): rule1 = await query_for_rule(session, e.adapter, "p", "data2_admin", "data2", "read") self.assertIsNotNone(rule1) self.assertTrue(rule1.is_deleted) - + rule2 = await query_for_rule(session, e.adapter, "p", "data2_admin", "data2", "write") self.assertIsNotNone(rule2) self.assertTrue(rule2.is_deleted) @@ -309,7 +304,7 @@ async def test_load_policy_ignores_soft_deleted(self): # Delete a policy await e.delete_permission_for_user("alice", "data1", "read") - + async with session_maker() as session: rule = await query_for_rule(session, e.adapter, "p", "alice", "data1", "read") self.assertIsNotNone(rule) @@ -329,7 +324,7 @@ async def test_load_policy_ignores_soft_deleted(self): async def test_load_filtered_policy_ignores_soft_deleted(self): """Test that load_filtered_policy ignores soft-deleted rules.""" e = await self.get_enforcer() - + # Delete a policy await e.delete_permission_for_user("bob", "data2", "write")