From 8911c1680849b9f2020f73aed0f04d81f24686d7 Mon Sep 17 00:00:00 2001 From: trbtm Date: Mon, 8 Jul 2024 16:02:35 +0200 Subject: [PATCH] feat: support for soft deletion of casbin rules (#72) * feat: add support for soft deletion of casbin rules * refactor: improve reusability of test case * fix: type hints * test: soft delete * chore: updated .gitignore * refactor: pass the sqlalchemy attribute itself instead of the attribute name string * test: softdelete flag in database * refactor: save_policy - load rules from db before making changes - improved comments * test: save_policy softdelete strategy * fix: formatted code with black * fix: do not create test.db by default * fix: units tests for CI/CD pipeline * docs: added Soft Delete example * fix: make sure softdelete filter is applied * docs: make usage of explicit * docs: moved softdelete logic into base class * docs: improvement * feat: validate the type of db_class_softdelete_attribute * fix: default value of is_deleted flag --- .gitignore | 5 +- README.md | 18 +++ casbin_sqlalchemy_adapter/adapter.py | 135 ++++++++++++++++++----- examples/softdelete.py | 91 +++++++++++++++ tests/test_adapter.py | 65 ++++++----- tests/test_adapter_softdelete.py | 158 +++++++++++++++++++++++++++ 6 files changed, 413 insertions(+), 59 deletions(-) create mode 100644 examples/softdelete.py create mode 100644 tests/test_adapter_softdelete.py diff --git a/.gitignore b/.gitignore index fdda8ce..cd9514c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,7 @@ venv.bak/ # mypy .mypy_cache/ -.idea \ No newline at end of file +.idea + +# vscode settings +.vscode diff --git a/README.md b/README.md index e01db03..5f23f65 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,24 @@ else: pass ``` +## Soft Delete example + +Soft Delete for casbin rules is supported, only when using a custom casbin rule model. +The Soft Delete mechanism is enabled by passing the attribute of the flag indicating whether +a rule is deleted to `db_class_softdelete_attribute`. +That attribute needs to be of type `sqlalchemy.Boolean`. + +```python +adapter = Adapter( + engine, + db_class=MyCustomCasbinRuleModel, + db_class_softdelete_attribute=MyCustomCasbinRuleModel.is_deleted, +) +``` + +Please be aware that this adapter only sets a flag like `is_deleted` to `True`. +The provided model needs to handle the update of fields like `deleted_by`, `deleted_at`, etc. +An example for this is given in [examples/softdelete.py](examples/softdelete.py). ### Getting Help diff --git a/casbin_sqlalchemy_adapter/adapter.py b/casbin_sqlalchemy_adapter/adapter.py index 75c4579..0e04ef0 100644 --- a/casbin_sqlalchemy_adapter/adapter.py +++ b/casbin_sqlalchemy_adapter/adapter.py @@ -2,8 +2,8 @@ import sqlalchemy from casbin import persist -from sqlalchemy import Column, Integer, String -from sqlalchemy import create_engine, or_ +from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy import create_engine, or_, not_ from sqlalchemy.orm import sessionmaker # declarative base class @@ -56,15 +56,33 @@ class Filter: class Adapter(persist.Adapter, persist.adapters.UpdateAdapter): """the interface for Casbin adapters.""" - def __init__(self, engine, db_class=None, filtered=False, create_all_models=True): + def __init__( + self, + engine, + db_class=None, + db_class_softdelete_attribute=None, + filtered=False, + create_all_models=True, + ): if isinstance(engine, str): self._engine = create_engine(engine) 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", @@ -102,7 +120,9 @@ def _session_scope(self): def load_policy(self, model): """loads all policy rules from the storage.""" with self._session_scope() as session: - lines = session.query(self._db_class).all() + query = session.query(self._db_class) + query = self._softdelete_query(query) + lines = query.all() for line in lines: persist.load_policy_line(str(line), model) @@ -113,6 +133,7 @@ def load_filtered_policy(self, model, filter) -> None: """loads all policy rules from the storage.""" with self._session_scope() as session: query = session.query(self._db_class) + query = self._softdelete_query(query) filters = self.filter_query(query, filter) filters = filters.all() @@ -140,15 +161,60 @@ def _save_policy_line(self, ptype, rule, session=None): 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: + with self._session_scope() as session: + query = session.query(self._db_class) + query.delete() + 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: + self._save_policy_line(ptype, rule, session=session) + return True + + # Custom stategy for softdelete since it does not make sense to recreate all of the + # entries when using soft delete with self._session_scope() as session: query = session.query(self._db_class) - query.delete() + query = self._softdelete_query(query) + + # Delete entries that are not part of the model anymore + lines_before_changes = query.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: - self._save_policy_line(ptype, rule, session=session) + # Filter for rule in the database + filter_query = query.filter(self._db_class.ptype == ptype) + for index, value in enumerate(rule): + v_value = getattr(self._db_class, "v{}".format(index)) + filter_query = filter_query.filter(v_value == value) + # If the rule is not present, create an entry in the database + if filter_query.count() == 0: + 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 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 def add_policy(self, sec, ptype, rule): @@ -164,10 +230,15 @@ def remove_policy(self, sec, ptype, rule): """removes a policy rule from the storage.""" with self._session_scope() as session: query = session.query(self._db_class) + query = self._softdelete_query(query) query = query.filter(self._db_class.ptype == ptype) for i, v in enumerate(rule): query = query.filter(getattr(self._db_class, "v{}".format(i)) == v) - r = query.delete() + + if self.softdelete_attribute is None: + r = query.delete() + else: + r = query.update({self.softdelete_attribute: True}) return True if r > 0 else False @@ -177,20 +248,27 @@ def remove_policies(self, sec, ptype, rules): return with self._session_scope() as session: query = session.query(self._db_class) + query = self._softdelete_query(query) query = query.filter(self._db_class.ptype == ptype) rules = zip(*rules) for i, rule in enumerate(rules): query = query.filter( or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule) ) - query.delete() + + if self.softdelete_attribute is None: + query.delete() + else: + query.update({self.softdelete_attribute: True}) 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. """ with self._session_scope() as session: - query = session.query(self._db_class).filter(self._db_class.ptype == ptype) + query = session.query(self._db_class) + query = self._softdelete_query(query) + query = query.filter(self._db_class.ptype == ptype) if not (0 <= field_index <= 5): return False @@ -200,12 +278,16 @@ def remove_filtered_policy(self, sec, ptype, field_index, *field_values): if v != "": v_value = getattr(self._db_class, "v{}".format(field_index + i)) query = query.filter(v_value == v) - r = query.delete() + + if self.softdelete_attribute is None: + r = query.delete() + else: + r = query.update({self.softdelete_attribute: True}) return True if r > 0 else False def update_policy( - self, sec: str, ptype: str, old_rule: [str], new_rule: [str] + self, sec: str, ptype: str, old_rule: list[str], new_rule: list[str] ) -> None: """ Update the old_rule with the new_rule in the database (storage). @@ -219,7 +301,9 @@ def update_policy( """ with self._session_scope() as session: - query = session.query(self._db_class).filter(self._db_class.ptype == ptype) + query = session.query(self._db_class) + query = self._softdelete_query(query) + query = query.filter(self._db_class.ptype == ptype) # locate the old rule for index, value in enumerate(old_rule): @@ -241,12 +325,8 @@ def update_policies( self, sec: str, ptype: str, - old_rules: [ - [str], - ], - new_rules: [ - [str], - ], + old_rules: list[list[str]], + new_rules: list[list[str]], ) -> None: """ Update the old_rules with the new_rules in the database (storage). @@ -262,8 +342,8 @@ def update_policies( self.update_policy(sec, ptype, old_rules[i], new_rules[i]) def update_filtered_policies( - self, sec, ptype, new_rules: [[str]], field_index, *field_values - ) -> [[str]]: + self, sec, ptype, new_rules: list[list[str]], field_index, *field_values + ) -> list[list[str]]: """update_filtered_policies updates all the policies on the basis of the filter.""" filter = Filter() @@ -278,16 +358,15 @@ def update_filtered_policies( self._update_filtered_policies(new_rules, filter) - def _update_filtered_policies(self, new_rules, filter) -> [[str]]: + def _update_filtered_policies(self, new_rules, filter) -> list[list[str]]: """_update_filtered_policies updates all the policies on the basis of the filter.""" with self._session_scope() as session: - # Load old policies - query = session.query(self._db_class).filter( - self._db_class.ptype == filter.ptype - ) + query = session.query(self._db_class) + query = self._softdelete_query(query) + query = query.filter(self._db_class.ptype == filter.ptype) filtered_query = self.filter_query(query, filter) old_rules = filtered_query.all() @@ -302,3 +381,9 @@ def _update_filtered_policies(self, new_rules, filter) -> [[str]]: # return deleted rules return old_rules + + def _softdelete_query(self, query): + query_softdelete = query + if self.softdelete_attribute is not None: + query_softdelete = query_softdelete.where(not_(self.softdelete_attribute)) + return query_softdelete diff --git a/examples/softdelete.py b/examples/softdelete.py new file mode 100644 index 0000000..8ee985c --- /dev/null +++ b/examples/softdelete.py @@ -0,0 +1,91 @@ +from datetime import datetime, UTC + +import casbin +from casbin_sqlalchemy_adapter import Base, Adapter +from sqlalchemy import false, Column, DateTime, String, Integer, Boolean +from sqlalchemy.engine.default import DefaultExecutionContext + +from some_user_library import get_current_user_id + + +def _deleted_at_default(context: DefaultExecutionContext) -> datetime | None: + current_parameters = context.get_current_parameters() + if current_parameters.get("is_deleted"): + return datetime.now(UTC) + else: + return None + + +def _deleted_by_default(context: DefaultExecutionContext) -> int | None: + current_parameters = context.get_current_parameters() + if current_parameters.get("is_deleted"): + return get_current_user_id() + else: + return None + + +class BaseModel(Base): + __abstract__ = True + + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column( + DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + nullable=False, + ) + deleted_at = Column( + DateTime, + default=_deleted_at_default, + onupdate=_deleted_at_default, + nullable=True, + ) + + created_by = Column(Integer, default=get_current_user_id, nullable=False) + updated_by = Column( + Integer, + default=get_current_user_id, + onupdate=get_current_user_id, + nullable=False, + ) + deleted_by = Column( + Integer, + default=_deleted_by_default, + onupdate=_deleted_by_default, + nullable=True, + ) + is_deleted = Column( + Boolean, + default=False, + server_default=false(), + index=True, + nullable=False, + ) + + +class CasbinSoftDeleteRule(BaseModel): + __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)) + + +engine = your_engine_factory() +# Initialize the Adapter, pass your custom CasbinRule model +# and pass the Boolean field indicating whether a rule is deleted or not +# your model needs to handle the update of fields +# 'updated_by', 'updated_at', 'deleted_by', etc. +adapter = Adapter( + engine, + CasbinSoftDeleteRule, + CasbinSoftDeleteRule.is_deleted, +) +# Create the Enforcer, etc. +e = casbin.Enforcer("path/to/model.conf", adapter) +... diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 726932e..d50e82c 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -1,5 +1,6 @@ import os from unittest import TestCase +from pathlib import Path import casbin from sqlalchemy import create_engine, Column, Integer, String @@ -11,35 +12,33 @@ from casbin_sqlalchemy_adapter.adapter import Filter -def get_fixture(path): - dir_path = os.path.split(os.path.realpath(__file__))[0] + "/" - return os.path.abspath(dir_path + path) - - -def get_enforcer(): - engine = create_engine("sqlite://") - # engine = create_engine('sqlite:///' + os.path.split(os.path.realpath(__file__))[0] + '/test.db', echo=True) - adapter = Adapter(engine) +class TestConfig(TestCase): + def get_enforcer(self): + engine = create_engine("sqlite://") + # engine = create_engine("sqlite:///" + os.path.split(os.path.realpath(__file__))[0] + "/test.db", echo=True) + adapter = Adapter(engine) - session = sessionmaker(bind=engine) - Base.metadata.create_all(engine) - s = session() - s.query(CasbinRule).delete() - s.add(CasbinRule(ptype="p", v0="alice", v1="data1", v2="read")) - s.add(CasbinRule(ptype="p", v0="bob", v1="data2", v2="write")) - s.add(CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="read")) - s.add(CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="write")) - s.add(CasbinRule(ptype="g", v0="alice", v1="data2_admin")) - s.commit() - s.close() + session = sessionmaker(bind=engine) + Base.metadata.create_all(engine) + s = session() + s.query(CasbinRule).delete() + s.add(CasbinRule(ptype="p", v0="alice", v1="data1", v2="read")) + s.add(CasbinRule(ptype="p", v0="bob", v1="data2", v2="write")) + s.add(CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="read")) + s.add(CasbinRule(ptype="p", v0="data2_admin", v1="data2", v2="write")) + s.add(CasbinRule(ptype="g", v0="alice", v1="data2_admin")) + s.commit() + s.close() - return casbin.Enforcer(get_fixture("rbac_model.conf"), adapter) + scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) + model_path = scriptdir / "rbac_model.conf" + return casbin.Enforcer(str(model_path), adapter) -class TestConfig(TestCase): def test_custom_db_class(self): class CustomRule(Base): __tablename__ = "casbin_rule2" + __table_args__ = {"extend_existing": True} id = Column(Integer, primary_key=True) ptype = Column(String(255)) @@ -62,7 +61,7 @@ class CustomRule(Base): self.assertEqual(s.query(CustomRule).all()[0].not_exist, "NotNone") def test_enforcer_basic(self): - e = get_enforcer() + e = self.get_enforcer() self.assertTrue(e.enforce("alice", "data1", "read")) self.assertFalse(e.enforce("alice", "data1", "write")) @@ -74,7 +73,7 @@ def test_enforcer_basic(self): self.assertTrue(e.enforce("alice", "data2", "write")) def test_add_policy(self): - e = get_enforcer() + e = self.get_enforcer() self.assertFalse(e.enforce("eve", "data3", "read")) res = e.add_policies((("eve", "data3", "read"), ("eve", "data4", "read"))) @@ -83,7 +82,7 @@ def test_add_policy(self): self.assertTrue(e.enforce("eve", "data4", "read")) def test_add_policies(self): - e = get_enforcer() + e = self.get_enforcer() self.assertFalse(e.enforce("eve", "data3", "read")) res = e.add_permission_for_user("eve", "data3", "read") @@ -91,7 +90,7 @@ def test_add_policies(self): self.assertTrue(e.enforce("eve", "data3", "read")) def test_save_policy(self): - e = get_enforcer() + e = self.get_enforcer() self.assertFalse(e.enforce("alice", "data4", "read")) model = e.get_model() @@ -104,7 +103,7 @@ def test_save_policy(self): self.assertTrue(e.enforce("alice", "data4", "read")) def test_remove_policy(self): - e = get_enforcer() + e = self.get_enforcer() self.assertFalse(e.enforce("alice", "data5", "read")) e.add_permission_for_user("alice", "data5", "read") @@ -113,7 +112,7 @@ def test_remove_policy(self): self.assertFalse(e.enforce("alice", "data5", "read")) def test_remove_policies(self): - e = get_enforcer() + e = self.get_enforcer() self.assertFalse(e.enforce("alice", "data5", "read")) self.assertFalse(e.enforce("alice", "data6", "read")) @@ -125,7 +124,7 @@ def test_remove_policies(self): self.assertFalse(e.enforce("alice", "data6", "read")) def test_remove_filtered_policy(self): - e = get_enforcer() + e = self.get_enforcer() self.assertTrue(e.enforce("alice", "data1", "read")) e.remove_filtered_policy(1, "data1") @@ -185,7 +184,7 @@ def test_repr(self): s.close() def test_filtered_policy(self): - e = get_enforcer() + e = self.get_enforcer() filter = Filter() filter.ptype = ["p"] @@ -307,7 +306,7 @@ def test_filtered_policy(self): self.assertTrue(e.enforce("data2_admin", "data2", "write")) def test_update_policy(self): - e = get_enforcer() + e = self.get_enforcer() example_p = ["mike", "cookie", "eat"] self.assertTrue(e.enforce("alice", "data1", "read")) @@ -336,7 +335,7 @@ def test_update_policy(self): self.assertFalse(e.enforce("bob", "data2", "write")) def test_update_policies(self): - e = get_enforcer() + e = self.get_enforcer() old_rule_0 = ["alice", "data1", "read"] old_rule_1 = ["bob", "data2", "write"] @@ -366,7 +365,7 @@ def test_update_policies(self): self.assertTrue(e.enforce("data2_admin", "data_test", "write")) def test_update_filtered_policies(self): - e = get_enforcer() + e = self.get_enforcer() e.update_filtered_policies( [ diff --git a/tests/test_adapter_softdelete.py b/tests/test_adapter_softdelete.py new file mode 100644 index 0000000..704f5aa --- /dev/null +++ b/tests/test_adapter_softdelete.py @@ -0,0 +1,158 @@ +import os +from pathlib import Path + +import casbin +from sqlalchemy import create_engine, Column, Boolean, Integer, String +from sqlalchemy.orm import sessionmaker + +from casbin_sqlalchemy_adapter import Adapter +from casbin_sqlalchemy_adapter import Base +from casbin_sqlalchemy_adapter.adapter import Filter + +from tests.test_adapter import TestConfig + + +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)) + + +def query_for_rule(session, adapter, ptype, v0, v1, v2): + rule_filter = Filter() + rule_filter.ptype = [ptype] + rule_filter.v0 = [v0] + rule_filter.v1 = [v1] + rule_filter.v2 = [v2] + query = session.query(CasbinRuleSoftDelete) + query = adapter.filter_query(query, rule_filter) + return query + + +class TestConfigSoftDelete(TestConfig): + def get_enforcer(self): + engine = create_engine("sqlite://") + # engine = create_engine("sqlite:///" + os.path.split(os.path.realpath(__file__))[0] + "/test.db",echo=True,) + adapter = Adapter(engine, CasbinRuleSoftDelete, CasbinRuleSoftDelete.is_deleted) + + session = sessionmaker(bind=engine) + Base.metadata.create_all(engine) + s = session() + s.query(CasbinRuleSoftDelete).delete() + 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")) + s.commit() + s.close() + + scriptdir = Path(os.path.dirname(os.path.realpath(__file__))) + model_path = scriptdir / "rbac_model.conf" + + return casbin.Enforcer(str(model_path), adapter) + + def test_custom_db_class(self): + 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_engine("sqlite://") + adapter = Adapter(engine, CustomRule, CustomRule.is_deleted) + + session = sessionmaker(bind=engine) + Base.metadata.create_all(engine) + s = session() + s.add(CustomRule(not_exist="NotNone")) + s.commit() + self.assertEqual(s.query(CustomRule).all()[0].not_exist, "NotNone") + + def test_softdelete_flag(self): + e = self.get_enforcer() + session = e.adapter.session_local() + query = query_for_rule(session, e.adapter, "p", "alice", "data5", "read") + + self.assertFalse(e.enforce("alice", "data5", "read")) + self.assertIsNone(query.first()) + e.add_permission_for_user("alice", "data5", "read") + self.assertTrue(e.enforce("alice", "data5", "read")) + self.assertTrue(query.count() == 1) + self.assertFalse(query.first().is_deleted) + e.delete_permission_for_user("alice", "data5", "read") + self.assertFalse(e.enforce("alice", "data5", "read")) + self.assertTrue(query.count() == 1) + self.assertTrue(query.first().is_deleted) + + def test_save_policy_softdelete(self): + e = self.get_enforcer() + session = e.adapter.session_local() + + # Turn off auto save + e.enable_auto_save(auto_save=False) + + # Delete some preexisting rules + e.delete_permission_for_user("alice", "data1", "read") + e.delete_permission_for_user("bob", "data2", "write") + # Delete a non existing rule + e.delete_permission_for_user("bob", "data100", "read") + # Add some new rules + e.add_permission_for_user("alice", "data100", "read") + e.add_permission_for_user("bob", "data100", "write") + + # Write changes to database + e.save_policy() + + self.assertTrue( + query_for_rule(session, e.adapter, "p", "alice", "data1", "read") + .first() + .is_deleted + ) + self.assertTrue( + query_for_rule(session, e.adapter, "p", "bob", "data2", "write") + .first() + .is_deleted + ) + self.assertIsNone( + query_for_rule(session, e.adapter, "p", "bob", "data100", "read").first() + ) + self.assertFalse( + query_for_rule(session, e.adapter, "p", "alice", "data100", "read") + .first() + .is_deleted + ) + self.assertFalse( + query_for_rule(session, e.adapter, "p", "bob", "data100", "write") + .first() + .is_deleted + )