Skip to content

Commit 8911c16

Browse files
authored
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
1 parent e9ff609 commit 8911c16

File tree

6 files changed

+413
-59
lines changed

6 files changed

+413
-59
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,7 @@ venv.bak/
102102

103103
# mypy
104104
.mypy_cache/
105-
.idea
105+
.idea
106+
107+
# vscode settings
108+
.vscode

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ else:
4949
pass
5050
```
5151

52+
## Soft Delete example
53+
54+
Soft Delete for casbin rules is supported, only when using a custom casbin rule model.
55+
The Soft Delete mechanism is enabled by passing the attribute of the flag indicating whether
56+
a rule is deleted to `db_class_softdelete_attribute`.
57+
That attribute needs to be of type `sqlalchemy.Boolean`.
58+
59+
```python
60+
adapter = Adapter(
61+
engine,
62+
db_class=MyCustomCasbinRuleModel,
63+
db_class_softdelete_attribute=MyCustomCasbinRuleModel.is_deleted,
64+
)
65+
```
66+
67+
Please be aware that this adapter only sets a flag like `is_deleted` to `True`.
68+
The provided model needs to handle the update of fields like `deleted_by`, `deleted_at`, etc.
69+
An example for this is given in [examples/softdelete.py](examples/softdelete.py).
5270

5371
### Getting Help
5472

casbin_sqlalchemy_adapter/adapter.py

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import sqlalchemy
44
from casbin import persist
5-
from sqlalchemy import Column, Integer, String
6-
from sqlalchemy import create_engine, or_
5+
from sqlalchemy import Column, Integer, String, Boolean
6+
from sqlalchemy import create_engine, or_, not_
77
from sqlalchemy.orm import sessionmaker
88

99
# declarative base class
@@ -56,15 +56,33 @@ class Filter:
5656
class Adapter(persist.Adapter, persist.adapters.UpdateAdapter):
5757
"""the interface for Casbin adapters."""
5858

59-
def __init__(self, engine, db_class=None, filtered=False, create_all_models=True):
59+
def __init__(
60+
self,
61+
engine,
62+
db_class=None,
63+
db_class_softdelete_attribute=None,
64+
filtered=False,
65+
create_all_models=True,
66+
):
6067
if isinstance(engine, str):
6168
self._engine = create_engine(engine)
6269
else:
6370
self._engine = engine
6471

72+
self.softdelete_attribute = None
73+
6574
if db_class is None:
6675
db_class = CasbinRule
6776
else:
77+
if db_class_softdelete_attribute is not None and not isinstance(
78+
db_class_softdelete_attribute.type, Boolean
79+
):
80+
msg = f"The type of db_class_softdelete_attribute needs to be {str(Boolean)!r}. "
81+
msg += f"An attribute of type {str(type(db_class_softdelete_attribute.type))!r} was given."
82+
raise ValueError(msg)
83+
# Softdelete is only supported when using custom class
84+
self.softdelete_attribute = db_class_softdelete_attribute
85+
6886
for attr in (
6987
"id",
7088
"ptype",
@@ -102,7 +120,9 @@ def _session_scope(self):
102120
def load_policy(self, model):
103121
"""loads all policy rules from the storage."""
104122
with self._session_scope() as session:
105-
lines = session.query(self._db_class).all()
123+
query = session.query(self._db_class)
124+
query = self._softdelete_query(query)
125+
lines = query.all()
106126
for line in lines:
107127
persist.load_policy_line(str(line), model)
108128

@@ -113,6 +133,7 @@ def load_filtered_policy(self, model, filter) -> None:
113133
"""loads all policy rules from the storage."""
114134
with self._session_scope() as session:
115135
query = session.query(self._db_class)
136+
query = self._softdelete_query(query)
116137
filters = self.filter_query(query, filter)
117138
filters = filters.all()
118139

@@ -140,15 +161,60 @@ def _save_policy_line(self, ptype, rule, session=None):
140161

141162
def save_policy(self, model):
142163
"""saves all policy rules to the storage."""
164+
165+
# Use the default strategy when soft delete is not enabled
166+
if self.softdelete_attribute is None:
167+
with self._session_scope() as session:
168+
query = session.query(self._db_class)
169+
query.delete()
170+
for sec in ["p", "g"]:
171+
if sec not in model.model.keys():
172+
continue
173+
for ptype, ast in model.model[sec].items():
174+
for rule in ast.policy:
175+
self._save_policy_line(ptype, rule, session=session)
176+
return True
177+
178+
# Custom stategy for softdelete since it does not make sense to recreate all of the
179+
# entries when using soft delete
143180
with self._session_scope() as session:
144181
query = session.query(self._db_class)
145-
query.delete()
182+
query = self._softdelete_query(query)
183+
184+
# Delete entries that are not part of the model anymore
185+
lines_before_changes = query.all()
186+
187+
# Create new entries in the database
146188
for sec in ["p", "g"]:
147189
if sec not in model.model.keys():
148190
continue
149191
for ptype, ast in model.model[sec].items():
150192
for rule in ast.policy:
151-
self._save_policy_line(ptype, rule, session=session)
193+
# Filter for rule in the database
194+
filter_query = query.filter(self._db_class.ptype == ptype)
195+
for index, value in enumerate(rule):
196+
v_value = getattr(self._db_class, "v{}".format(index))
197+
filter_query = filter_query.filter(v_value == value)
198+
# If the rule is not present, create an entry in the database
199+
if filter_query.count() == 0:
200+
self._save_policy_line(ptype, rule, session=session)
201+
202+
for line in lines_before_changes:
203+
ptype = line.ptype
204+
sec = ptype[0] # derived from persist.load_policy_line function
205+
fields_with_None = [
206+
line.v0,
207+
line.v1,
208+
line.v2,
209+
line.v3,
210+
line.v4,
211+
line.v5,
212+
]
213+
rule = [element for element in fields_with_None if element is not None]
214+
# If the the rule is not part of the model, set the deletion flag to True
215+
if not model.has_policy(sec, ptype, rule):
216+
setattr(line, self.softdelete_attribute.name, True)
217+
152218
return True
153219

154220
def add_policy(self, sec, ptype, rule):
@@ -164,10 +230,15 @@ def remove_policy(self, sec, ptype, rule):
164230
"""removes a policy rule from the storage."""
165231
with self._session_scope() as session:
166232
query = session.query(self._db_class)
233+
query = self._softdelete_query(query)
167234
query = query.filter(self._db_class.ptype == ptype)
168235
for i, v in enumerate(rule):
169236
query = query.filter(getattr(self._db_class, "v{}".format(i)) == v)
170-
r = query.delete()
237+
238+
if self.softdelete_attribute is None:
239+
r = query.delete()
240+
else:
241+
r = query.update({self.softdelete_attribute: True})
171242

172243
return True if r > 0 else False
173244

@@ -177,20 +248,27 @@ def remove_policies(self, sec, ptype, rules):
177248
return
178249
with self._session_scope() as session:
179250
query = session.query(self._db_class)
251+
query = self._softdelete_query(query)
180252
query = query.filter(self._db_class.ptype == ptype)
181253
rules = zip(*rules)
182254
for i, rule in enumerate(rules):
183255
query = query.filter(
184256
or_(getattr(self._db_class, "v{}".format(i)) == v for v in rule)
185257
)
186-
query.delete()
258+
259+
if self.softdelete_attribute is None:
260+
query.delete()
261+
else:
262+
query.update({self.softdelete_attribute: True})
187263

188264
def remove_filtered_policy(self, sec, ptype, field_index, *field_values):
189265
"""removes policy rules that match the filter from the storage.
190266
This is part of the Auto-Save feature.
191267
"""
192268
with self._session_scope() as session:
193-
query = session.query(self._db_class).filter(self._db_class.ptype == ptype)
269+
query = session.query(self._db_class)
270+
query = self._softdelete_query(query)
271+
query = query.filter(self._db_class.ptype == ptype)
194272

195273
if not (0 <= field_index <= 5):
196274
return False
@@ -200,12 +278,16 @@ def remove_filtered_policy(self, sec, ptype, field_index, *field_values):
200278
if v != "":
201279
v_value = getattr(self._db_class, "v{}".format(field_index + i))
202280
query = query.filter(v_value == v)
203-
r = query.delete()
281+
282+
if self.softdelete_attribute is None:
283+
r = query.delete()
284+
else:
285+
r = query.update({self.softdelete_attribute: True})
204286

205287
return True if r > 0 else False
206288

207289
def update_policy(
208-
self, sec: str, ptype: str, old_rule: [str], new_rule: [str]
290+
self, sec: str, ptype: str, old_rule: list[str], new_rule: list[str]
209291
) -> None:
210292
"""
211293
Update the old_rule with the new_rule in the database (storage).
@@ -219,7 +301,9 @@ def update_policy(
219301
"""
220302

221303
with self._session_scope() as session:
222-
query = session.query(self._db_class).filter(self._db_class.ptype == ptype)
304+
query = session.query(self._db_class)
305+
query = self._softdelete_query(query)
306+
query = query.filter(self._db_class.ptype == ptype)
223307

224308
# locate the old rule
225309
for index, value in enumerate(old_rule):
@@ -241,12 +325,8 @@ def update_policies(
241325
self,
242326
sec: str,
243327
ptype: str,
244-
old_rules: [
245-
[str],
246-
],
247-
new_rules: [
248-
[str],
249-
],
328+
old_rules: list[list[str]],
329+
new_rules: list[list[str]],
250330
) -> None:
251331
"""
252332
Update the old_rules with the new_rules in the database (storage).
@@ -262,8 +342,8 @@ def update_policies(
262342
self.update_policy(sec, ptype, old_rules[i], new_rules[i])
263343

264344
def update_filtered_policies(
265-
self, sec, ptype, new_rules: [[str]], field_index, *field_values
266-
) -> [[str]]:
345+
self, sec, ptype, new_rules: list[list[str]], field_index, *field_values
346+
) -> list[list[str]]:
267347
"""update_filtered_policies updates all the policies on the basis of the filter."""
268348

269349
filter = Filter()
@@ -278,16 +358,15 @@ def update_filtered_policies(
278358

279359
self._update_filtered_policies(new_rules, filter)
280360

281-
def _update_filtered_policies(self, new_rules, filter) -> [[str]]:
361+
def _update_filtered_policies(self, new_rules, filter) -> list[list[str]]:
282362
"""_update_filtered_policies updates all the policies on the basis of the filter."""
283363

284364
with self._session_scope() as session:
285-
286365
# Load old policies
287366

288-
query = session.query(self._db_class).filter(
289-
self._db_class.ptype == filter.ptype
290-
)
367+
query = session.query(self._db_class)
368+
query = self._softdelete_query(query)
369+
query = query.filter(self._db_class.ptype == filter.ptype)
291370
filtered_query = self.filter_query(query, filter)
292371
old_rules = filtered_query.all()
293372

@@ -302,3 +381,9 @@ def _update_filtered_policies(self, new_rules, filter) -> [[str]]:
302381
# return deleted rules
303382

304383
return old_rules
384+
385+
def _softdelete_query(self, query):
386+
query_softdelete = query
387+
if self.softdelete_attribute is not None:
388+
query_softdelete = query_softdelete.where(not_(self.softdelete_attribute))
389+
return query_softdelete

examples/softdelete.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from datetime import datetime, UTC
2+
3+
import casbin
4+
from casbin_sqlalchemy_adapter import Base, Adapter
5+
from sqlalchemy import false, Column, DateTime, String, Integer, Boolean
6+
from sqlalchemy.engine.default import DefaultExecutionContext
7+
8+
from some_user_library import get_current_user_id
9+
10+
11+
def _deleted_at_default(context: DefaultExecutionContext) -> datetime | None:
12+
current_parameters = context.get_current_parameters()
13+
if current_parameters.get("is_deleted"):
14+
return datetime.now(UTC)
15+
else:
16+
return None
17+
18+
19+
def _deleted_by_default(context: DefaultExecutionContext) -> int | None:
20+
current_parameters = context.get_current_parameters()
21+
if current_parameters.get("is_deleted"):
22+
return get_current_user_id()
23+
else:
24+
return None
25+
26+
27+
class BaseModel(Base):
28+
__abstract__ = True
29+
30+
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)
31+
updated_at = Column(
32+
DateTime,
33+
default=lambda: datetime.now(UTC),
34+
onupdate=lambda: datetime.now(UTC),
35+
nullable=False,
36+
)
37+
deleted_at = Column(
38+
DateTime,
39+
default=_deleted_at_default,
40+
onupdate=_deleted_at_default,
41+
nullable=True,
42+
)
43+
44+
created_by = Column(Integer, default=get_current_user_id, nullable=False)
45+
updated_by = Column(
46+
Integer,
47+
default=get_current_user_id,
48+
onupdate=get_current_user_id,
49+
nullable=False,
50+
)
51+
deleted_by = Column(
52+
Integer,
53+
default=_deleted_by_default,
54+
onupdate=_deleted_by_default,
55+
nullable=True,
56+
)
57+
is_deleted = Column(
58+
Boolean,
59+
default=False,
60+
server_default=false(),
61+
index=True,
62+
nullable=False,
63+
)
64+
65+
66+
class CasbinSoftDeleteRule(BaseModel):
67+
__tablename__ = "casbin_rule"
68+
69+
id = Column(Integer, primary_key=True)
70+
ptype = Column(String(255))
71+
v0 = Column(String(255))
72+
v1 = Column(String(255))
73+
v2 = Column(String(255))
74+
v3 = Column(String(255))
75+
v4 = Column(String(255))
76+
v5 = Column(String(255))
77+
78+
79+
engine = your_engine_factory()
80+
# Initialize the Adapter, pass your custom CasbinRule model
81+
# and pass the Boolean field indicating whether a rule is deleted or not
82+
# your model needs to handle the update of fields
83+
# 'updated_by', 'updated_at', 'deleted_by', etc.
84+
adapter = Adapter(
85+
engine,
86+
CasbinSoftDeleteRule,
87+
CasbinSoftDeleteRule.is_deleted,
88+
)
89+
# Create the Enforcer, etc.
90+
e = casbin.Enforcer("path/to/model.conf", adapter)
91+
...

0 commit comments

Comments
 (0)