blob: 52a5c697d5efa0c742485529ad19d292a5292216 [file]
# 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
import unittest
from unittest import IsolatedAsyncioTestCase
from pathlib import Path
import casbin
from sqlalchemy import Column, Integer, String, Boolean, select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from casbin_async_sqlalchemy_adapter import Adapter, 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 '<CasbinRule {}: "{}">'.format(self.id, str(self))
def get_fixture(path):
dir_path = os.path.split(os.path.realpath(__file__))[0] + "/"
return os.path.abspath(dir_path + path)
async def query_for_rule(session, adapter, ptype, v0, v1, v2):
"""Query for a specific rule in the database."""
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().all()
class TestConfigSoftDelete(IsolatedAsyncioTestCase):
async def get_enforcer(self):
engine = create_async_engine("sqlite+aiosqlite://", future=True)
adapter = Adapter(engine, CasbinRuleSoftDelete, CasbinRuleSoftDelete.is_deleted)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async with async_session() as s:
# Clear any existing data
await s.execute(select(CasbinRuleSoftDelete))
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()
e = casbin.AsyncEnforcer(get_fixture("rbac_model.conf"), adapter)
await e.load_policy()
return e
async def test_custom_db_class(self):
"""Test that custom db class with soft delete attribute 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)
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async with async_session() as s:
s.add(CustomRule(not_exist="NotNone"))
await s.commit()
result = await s.execute(select(CustomRule))
self.assertEqual(result.scalars().all()[0].not_exist, "NotNone")
async def test_softdelete_flag(self):
"""Test that soft delete flag is set when removing a policy."""
e = await self.get_enforcer()
engine = e.get_adapter()._engine
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async with async_session() as session:
# Initially, rule doesn't exist
query_result = await query_for_rule(session, e.get_adapter(), "p", "alice", "data5", "read")
self.assertFalse(e.enforce("alice", "data5", "read"))
self.assertEqual(len(query_result), 0)
# Add the rule
await e.add_permission_for_user("alice", "data5", "read")
await session.commit()
async with async_session() as session:
# Rule exists and is not deleted
query_result = await query_for_rule(session, e.get_adapter(), "p", "alice", "data5", "read")
self.assertTrue(e.enforce("alice", "data5", "read"))
self.assertEqual(len(query_result), 1)
self.assertFalse(query_result[0].is_deleted)
# Remove the rule (soft delete)
await e.delete_permission_for_user("alice", "data5", "read")
await session.commit()
async with async_session() as session:
# Rule still exists in DB but is marked as deleted
stmt = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v0 == "alice",
CasbinRuleSoftDelete.v1 == "data5",
CasbinRuleSoftDelete.v2 == "read"
)
result = await session.execute(stmt)
all_results = result.scalars().all()
self.assertFalse(e.enforce("alice", "data5", "read"))
self.assertEqual(len(all_results), 1)
self.assertTrue(all_results[0].is_deleted)
async def test_save_policy_softdelete(self):
"""Test that save_policy handles soft delete correctly."""
e = await self.get_enforcer()
engine = e.get_adapter()._engine
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
# Turn off auto save
e.enable_auto_save(auto_save=False)
# Get model and manipulate policies directly
model = e.get_model()
# Delete some preexisting rules from model (not from DB yet)
model.remove_policy("p", "p", ["alice", "data1", "read"])
model.remove_policy("p", "p", ["bob", "data2", "write"])
# Add some new rules to model (not in DB yet)
model.add_policy("p", "p", ["alice", "data100", "read"])
model.add_policy("p", "p", ["bob", "data100", "write"])
# Write changes to database - this should soft-delete removed rules
await e.save_policy()
async with async_session() as session:
# Check that deleted rules are marked as deleted
stmt1 = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v0 == "alice",
CasbinRuleSoftDelete.v1 == "data1",
CasbinRuleSoftDelete.v2 == "read"
)
result1 = await session.execute(stmt1)
alice_data1 = result1.scalars().first()
self.assertIsNotNone(alice_data1)
self.assertTrue(alice_data1.is_deleted)
stmt2 = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v0 == "bob",
CasbinRuleSoftDelete.v1 == "data2",
CasbinRuleSoftDelete.v2 == "write"
)
result2 = await session.execute(stmt2)
bob_data2 = result2.scalars().first()
self.assertIsNotNone(bob_data2)
self.assertTrue(bob_data2.is_deleted)
# Check that new rules are not deleted
stmt4 = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v0 == "alice",
CasbinRuleSoftDelete.v1 == "data100",
CasbinRuleSoftDelete.v2 == "read"
)
result4 = await session.execute(stmt4)
alice_data100 = result4.scalars().first()
self.assertIsNotNone(alice_data100)
self.assertFalse(alice_data100.is_deleted)
stmt5 = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v0 == "bob",
CasbinRuleSoftDelete.v1 == "data100",
CasbinRuleSoftDelete.v2 == "write"
)
result5 = await session.execute(stmt5)
bob_data100 = result5.scalars().first()
self.assertIsNotNone(bob_data100)
self.assertFalse(bob_data100.is_deleted)
async def test_enforcer_basic(self):
"""Test that basic enforcement works with soft delete."""
e = await self.get_enforcer()
self.assertTrue(e.enforce("alice", "data1", "read"))
self.assertFalse(e.enforce("alice", "data1", "write"))
self.assertFalse(e.enforce("bob", "data1", "read"))
self.assertFalse(e.enforce("bob", "data1", "write"))
self.assertTrue(e.enforce("bob", "data2", "write"))
self.assertFalse(e.enforce("bob", "data2", "read"))
self.assertTrue(e.enforce("alice", "data2", "read"))
self.assertTrue(e.enforce("alice", "data2", "write"))
async def test_remove_policies_softdelete(self):
"""Test that remove_policies works with soft delete."""
e = await self.get_enforcer()
engine = e.get_adapter()._engine
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
self.assertFalse(e.enforce("alice", "data5", "read"))
self.assertFalse(e.enforce("alice", "data6", "read"))
await e.add_policies((("alice", "data5", "read"), ("alice", "data6", "read")))
self.assertTrue(e.enforce("alice", "data5", "read"))
self.assertTrue(e.enforce("alice", "data6", "read"))
await e.remove_policies((("alice", "data5", "read"), ("alice", "data6", "read")))
self.assertFalse(e.enforce("alice", "data5", "read"))
self.assertFalse(e.enforce("alice", "data6", "read"))
# Verify soft deletion
async with async_session() as session:
stmt = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v0 == "alice"
)
result = await session.execute(stmt)
all_alice_rules = result.scalars().all()
# Find the soft-deleted rules
deleted_rules = [r for r in all_alice_rules if r.v1 in ["data5", "data6"] and r.is_deleted]
self.assertEqual(len(deleted_rules), 2)
async def test_remove_filtered_policy_softdelete(self):
"""Test that remove_filtered_policy works with soft delete."""
e = await self.get_enforcer()
engine = e.get_adapter()._engine
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
self.assertTrue(e.enforce("alice", "data1", "read"))
await e.remove_filtered_policy(1, "data1")
self.assertFalse(e.enforce("alice", "data1", "read"))
# Verify soft deletion
async with async_session() as session:
stmt = select(CasbinRuleSoftDelete).where(
CasbinRuleSoftDelete.ptype == "p",
CasbinRuleSoftDelete.v1 == "data1"
)
result = await session.execute(stmt)
data1_rules = result.scalars().all()
self.assertTrue(all(r.is_deleted for r in data1_rules))
async def test_invalid_softdelete_attribute_type(self):
"""Test that invalid soft delete attribute type 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 - should be Boolean
engine = create_async_engine("sqlite+aiosqlite://", future=True)
with self.assertRaises(ValueError) as context:
adapter = Adapter(engine, InvalidRule, InvalidRule.is_deleted)
self.assertIn("Boolean", str(context.exception))
if __name__ == "__main__":
unittest.main()