From 989280b3d37b61ea8acc2a4e5b914138285775e7 Mon Sep 17 00:00:00 2001 From: "qwen.ai[bot]" Date: Sun, 21 Jun 2026 06:00:09 +0000 Subject: [PATCH] Add authorization infrastructure with Casbin policy management - New SQLAlchemy models for authorization including CasbinRuleModel, PermissionModel, AuthorizationResourceModel, RoleModel, RolePermissionModel, and UserHasRoleModel with proper relationships and constraints - Implementation of SQLAlchemyCasbinPolicyRepository with full CRUD operations for roles, permissions, and policies including cursor-based pagination - CasbinAuthorizationService integrating with the repository layer and providing dynamic enforcer building for access control decisions - Infrastructure setup for authorization module with proper domain entities, services, and repository patterns following the application architecture This commit establishes a comprehensive authorization system backend with database persistence, supporting role-based access control through Casbin policies and efficient data management through SQLAlchemy ORM. --- .gitignore | 45 -- .../infrastructure/models/__init__.py | 0 .../models/casbin_rule_model.py | 16 + .../infrastructure/models/permission_model.py | 20 + .../infrastructure/models/resource_model.py | 13 + .../infrastructure/models/role_model.py | 12 + .../models/role_permission_model.py | 20 + .../models/user_has_role_model.py | 16 + .../infrastructure/repositories/__init__.py | 0 .../repositories/casbin_policy_repository.py | 501 ++++++++++++++++++ .../infrastructure/services/__init__.py | 0 .../services/casbin_authorization_service.py | 137 +++++ .../authorization/application/__init__.py | 0 .../application/create_permission/__init__.py | 0 .../application/create_role/__init__.py | 0 .../application/delete_permission/__init__.py | 0 .../application/delete_role/__init__.py | 0 .../application/get_permission/__init__.py | 0 .../application/get_role/__init__.py | 0 .../application/list_permissions/__init__.py | 0 .../application/list_roles/__init__.py | 0 .../application/update_permission/__init__.py | 0 .../application/update_role/__init__.py | 0 src/modules/authorization/domain/__init__.py | 0 .../authorization/domain/entities/__init__.py | 0 .../authorization/domain/services/__init__.py | 0 .../authorization/infrastructure/__init__.py | 0 .../infrastructure/models/__init__.py | 0 .../infrastructure/repositories/__init__.py | 0 .../infrastructure/services/__init__.py | 0 .../authorization/presentation/__init__.py | 0 .../presentation/routers/__init__.py | 0 .../presentation/schema/__init__.py | 0 33 files changed, 735 insertions(+), 45 deletions(-) create mode 100644 src/core/authorization/infrastructure/models/__init__.py create mode 100644 src/core/authorization/infrastructure/models/casbin_rule_model.py create mode 100644 src/core/authorization/infrastructure/models/permission_model.py create mode 100644 src/core/authorization/infrastructure/models/resource_model.py create mode 100644 src/core/authorization/infrastructure/models/role_model.py create mode 100644 src/core/authorization/infrastructure/models/role_permission_model.py create mode 100644 src/core/authorization/infrastructure/models/user_has_role_model.py create mode 100644 src/core/authorization/infrastructure/repositories/__init__.py create mode 100644 src/core/authorization/infrastructure/repositories/casbin_policy_repository.py create mode 100644 src/core/authorization/infrastructure/services/__init__.py create mode 100644 src/core/authorization/infrastructure/services/casbin_authorization_service.py create mode 100644 src/modules/authorization/application/__init__.py create mode 100644 src/modules/authorization/application/create_permission/__init__.py create mode 100644 src/modules/authorization/application/create_role/__init__.py create mode 100644 src/modules/authorization/application/delete_permission/__init__.py create mode 100644 src/modules/authorization/application/delete_role/__init__.py create mode 100644 src/modules/authorization/application/get_permission/__init__.py create mode 100644 src/modules/authorization/application/get_role/__init__.py create mode 100644 src/modules/authorization/application/list_permissions/__init__.py create mode 100644 src/modules/authorization/application/list_roles/__init__.py create mode 100644 src/modules/authorization/application/update_permission/__init__.py create mode 100644 src/modules/authorization/application/update_role/__init__.py create mode 100644 src/modules/authorization/domain/__init__.py create mode 100644 src/modules/authorization/domain/entities/__init__.py create mode 100644 src/modules/authorization/domain/services/__init__.py create mode 100644 src/modules/authorization/infrastructure/__init__.py create mode 100644 src/modules/authorization/infrastructure/models/__init__.py create mode 100644 src/modules/authorization/infrastructure/repositories/__init__.py create mode 100644 src/modules/authorization/infrastructure/services/__init__.py create mode 100644 src/modules/authorization/presentation/__init__.py create mode 100644 src/modules/authorization/presentation/routers/__init__.py create mode 100644 src/modules/authorization/presentation/schema/__init__.py diff --git a/.gitignore b/.gitignore index 9ab1037..cf1f837 100644 --- a/.gitignore +++ b/.gitignore @@ -1,51 +1,6 @@ ``` -# Python __pycache__/ *.pyc *.pyo *.pyd -.Python -*.so -*.egg-info/ -.eggs/ - -# Virtual environments -venv/ -.venv/ -env/ -ENV/ -.ENV - -# Build artifacts -build/ -dist/ -*.egg - -# Testing -.coverage -htmlcov/ -.pytest_cache/ -.mypy_cache/ - -# Logs -*.log - -# Environment variables -.env -.env.local -*.env.* - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*.tmp - -# OS -.DS_Store -Thumbs.db - -# Coverage -coverage/ ``` \ No newline at end of file diff --git a/src/core/authorization/infrastructure/models/__init__.py b/src/core/authorization/infrastructure/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/authorization/infrastructure/models/casbin_rule_model.py b/src/core/authorization/infrastructure/models/casbin_rule_model.py new file mode 100644 index 0000000..97be56a --- /dev/null +++ b/src/core/authorization/infrastructure/models/casbin_rule_model.py @@ -0,0 +1,16 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database.model import Base + + +class CasbinRuleModel(Base): + __tablename__ = "casbin_rules" + + ptype: Mapped[str] = mapped_column(String(16), index=True) + v0: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True) + v1: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True) + v2: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True) + v3: Mapped[str | None] = mapped_column(String(255), nullable=True) + v4: Mapped[str | None] = mapped_column(String(255), nullable=True) + v5: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/src/core/authorization/infrastructure/models/permission_model.py b/src/core/authorization/infrastructure/models/permission_model.py new file mode 100644 index 0000000..ac3eab2 --- /dev/null +++ b/src/core/authorization/infrastructure/models/permission_model.py @@ -0,0 +1,20 @@ +from uuid import UUID + +from sqlalchemy import String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class PermissionModel(Base, TimeStampMixin, SoftDeleteMixin): + __tablename__ = "permissions" + __table_args__ = ( + UniqueConstraint("resource", "action", name="uq_permissions_resource_action"), + ) + + key: Mapped[str] = mapped_column(String(255), unique=True, index=True) + resource_id: Mapped[UUID] = mapped_column(index=True) + resource: Mapped[str] = mapped_column(String(100), index=True) + action: Mapped[str] = mapped_column(String(100), index=True) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/src/core/authorization/infrastructure/models/resource_model.py b/src/core/authorization/infrastructure/models/resource_model.py new file mode 100644 index 0000000..95c2082 --- /dev/null +++ b/src/core/authorization/infrastructure/models/resource_model.py @@ -0,0 +1,13 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class AuthorizationResourceModel(Base, TimeStampMixin, SoftDeleteMixin): + __tablename__ = "authorization_resources" + + key: Mapped[str] = mapped_column(String(100), unique=True, index=True) + name: Mapped[str] = mapped_column(String(150), nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/src/core/authorization/infrastructure/models/role_model.py b/src/core/authorization/infrastructure/models/role_model.py new file mode 100644 index 0000000..7be2550 --- /dev/null +++ b/src/core/authorization/infrastructure/models/role_model.py @@ -0,0 +1,12 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class RoleModel(Base, TimeStampMixin, SoftDeleteMixin): + __tablename__ = "roles" + + name: Mapped[str] = mapped_column(String(100), unique=True, index=True) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/src/core/authorization/infrastructure/models/role_permission_model.py b/src/core/authorization/infrastructure/models/role_permission_model.py new file mode 100644 index 0000000..a7d924c --- /dev/null +++ b/src/core/authorization/infrastructure/models/role_permission_model.py @@ -0,0 +1,20 @@ +from uuid import UUID + +from sqlalchemy import UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database.model import Base + + +class RolePermissionModel(Base): + __tablename__ = "role_permissions" + __table_args__ = ( + UniqueConstraint( + "role_id", + "permission_id", + name="uq_role_permissions_role_id_permission_id", + ), + ) + + role_id: Mapped[UUID] = mapped_column(index=True) + permission_id: Mapped[UUID] = mapped_column(index=True) diff --git a/src/core/authorization/infrastructure/models/user_has_role_model.py b/src/core/authorization/infrastructure/models/user_has_role_model.py new file mode 100644 index 0000000..5849a38 --- /dev/null +++ b/src/core/authorization/infrastructure/models/user_has_role_model.py @@ -0,0 +1,16 @@ +from uuid import UUID + +from sqlalchemy import UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database.model import Base + + +class UserHasRoleModel(Base): + __tablename__ = "user_has_roles" + __table_args__ = ( + UniqueConstraint("user_id", "role_id", name="uq_user_has_roles_user_id_role_id"), + ) + + user_id: Mapped[UUID] = mapped_column(index=True) + role_id: Mapped[UUID] = mapped_column(index=True) diff --git a/src/core/authorization/infrastructure/repositories/__init__.py b/src/core/authorization/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/authorization/infrastructure/repositories/casbin_policy_repository.py b/src/core/authorization/infrastructure/repositories/casbin_policy_repository.py new file mode 100644 index 0000000..0b98669 --- /dev/null +++ b/src/core/authorization/infrastructure/repositories/casbin_policy_repository.py @@ -0,0 +1,501 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import and_, delete, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.authorization.infrastructure.models.casbin_rule_model import ( + CasbinRuleModel, +) +from src.core.authorization.infrastructure.models.permission_model import ( + PermissionModel, +) +from src.core.authorization.infrastructure.models.resource_model import ( + AuthorizationResourceModel, +) +from src.core.authorization.infrastructure.models.role_model import RoleModel +from src.core.authorization.infrastructure.models.role_permission_model import ( + RolePermissionModel, +) +from src.core.authorization.infrastructure.models.user_has_role_model import ( + UserHasRoleModel, +) +from src.modules.authorization import AuthorizationResource, Permission, Role +from src.shared.utils.cursor import CursorDirection + + +class SQLAlchemyCasbinPolicyRepository: + def __init__(self, db: AsyncSession): + self._db = db + + async def load_policy_lines(self) -> list[str]: + result = await self._db.execute(select(CasbinRuleModel)) + rules = result.scalars().all() + return [self._to_policy_line(rule) for rule in rules] + + async def list_policies(self) -> list[tuple[str, ...]]: + result = await self._db.execute(select(CasbinRuleModel)) + return [self._to_policy_tuple(rule) for rule in result.scalars().all()] + + async def add_policy(self, ptype: str, *values: str) -> None: + existing = await self._db.execute( + select(CasbinRuleModel).where( + CasbinRuleModel.ptype == ptype, + CasbinRuleModel.v0 == self._value_at(values, 0), + CasbinRuleModel.v1 == self._value_at(values, 1), + CasbinRuleModel.v2 == self._value_at(values, 2), + CasbinRuleModel.v3 == self._value_at(values, 3), + CasbinRuleModel.v4 == self._value_at(values, 4), + CasbinRuleModel.v5 == self._value_at(values, 5), + ) + ) + if existing.scalar_one_or_none(): + return + + self._db.add( + CasbinRuleModel( + ptype=ptype, + v0=self._value_at(values, 0), + v1=self._value_at(values, 1), + v2=self._value_at(values, 2), + v3=self._value_at(values, 3), + v4=self._value_at(values, 4), + v5=self._value_at(values, 5), + ) + ) + await self._db.flush() + + async def remove_policy(self, ptype: str, *values: str) -> None: + await self._db.execute( + delete(CasbinRuleModel).where( + CasbinRuleModel.ptype == ptype, + CasbinRuleModel.v0 == self._value_at(values, 0), + CasbinRuleModel.v1 == self._value_at(values, 1), + CasbinRuleModel.v2 == self._value_at(values, 2), + CasbinRuleModel.v3 == self._value_at(values, 3), + CasbinRuleModel.v4 == self._value_at(values, 4), + CasbinRuleModel.v5 == self._value_at(values, 5), + ) + ) + await self._db.flush() + + async def assign_role(self, subject: str, role: str) -> None: + role_model = await self._get_role_by_name(role) + if role_model is None: + raise ValueError(f"Role does not exist: {role}") + + user_id = UUID(subject) + existing_assignment = await self._db.execute( + select(UserHasRoleModel).where( + UserHasRoleModel.user_id == user_id, + UserHasRoleModel.role_id == role_model.id, + ) + ) + if existing_assignment.scalar_one_or_none() is None: + self._db.add(UserHasRoleModel(user_id=user_id, role_id=role_model.id)) + await self._db.flush() + + await self.add_policy("g", subject, role) + + async def get_roles_for_subject(self, subject: str) -> list[str]: + user_id = UUID(subject) + result = await self._db.execute( + select(RoleModel.name) + .join(UserHasRoleModel, UserHasRoleModel.role_id == RoleModel.id) + .where(UserHasRoleModel.user_id == user_id) + ) + return list(result.scalars().all()) + + async def create_resource( + self, + resource: AuthorizationResource, + ) -> AuthorizationResource: + model = AuthorizationResourceModel( + id=resource.id, + key=resource.key, + name=resource.name, + description=resource.description, + ) + self._db.add(model) + await self._db.flush() + return self._resource_from_model(model) + + async def list_resources(self) -> list[AuthorizationResource]: + result = await self._db.execute(select(AuthorizationResourceModel)) + return [self._resource_from_model(model) for model in result.scalars().all()] + + async def create_role(self, role: Role) -> Role: + model = RoleModel( + id=role.id, + name=role.name, + description=role.description, + ) + self._db.add(model) + await self._db.flush() + return self._role_from_model(model) + + async def get_role(self, role_id: UUID) -> Role | None: + result = await self._db.execute( + select(RoleModel).where(RoleModel.id == role_id) + ) + model = result.scalar_one_or_none() + if model is None: + return None + return self._role_from_model(model) + + async def list_roles(self) -> list[Role]: + result = await self._db.execute(select(RoleModel)) + return [self._role_from_model(model) for model in result.scalars().all()] + + async def list_roles_cursor( + self, + cursor_created_at: datetime | None = None, + cursor_id: UUID | None = None, + limit: int = 10, + direction: CursorDirection = CursorDirection.DIRECTION_NEXT, + ) -> tuple[list[Role], bool]: + query = select(RoleModel) + query = self._apply_cursor_pagination( + query, + RoleModel, + cursor_created_at, + cursor_id, + direction, + ).limit(limit + 1) + + result = await self._db.execute(query) + models = list(result.scalars().all()) + has_more = len(models) > limit + models = models[:limit] + + if direction == CursorDirection.DIRECTION_PREV: + models = list(reversed(models)) + + return [self._role_from_model(model) for model in models], has_more + + async def update_role(self, role: Role) -> Role | None: + result = await self._db.execute( + select(RoleModel).where(RoleModel.id == role.id) + ) + model = result.scalar_one_or_none() + if model is None: + return None + + old_name = model.name + model.name = role.name + model.description = role.description + await self._db.flush() + + if old_name != role.name: + await self._rename_role_policies(old_name, role.name) + + return self._role_from_model(model) + + async def delete_role(self, role_id: UUID) -> None: + role = await self.get_role(role_id) + if role is None: + return + + await self._db.execute( + delete(RolePermissionModel).where(RolePermissionModel.role_id == role_id) + ) + await self._db.execute( + delete(UserHasRoleModel).where(UserHasRoleModel.role_id == role_id) + ) + await self._db.execute(delete(RoleModel).where(RoleModel.id == role_id)) + await self._db.execute( + delete(CasbinRuleModel).where( + ((CasbinRuleModel.ptype == "p") & (CasbinRuleModel.v0 == role.name)) + | ((CasbinRuleModel.ptype == "g") & (CasbinRuleModel.v1 == role.name)) + ) + ) + await self._db.flush() + + async def create_permission(self, permission: Permission) -> Permission: + resource = await self._get_or_create_resource(permission.resource) + model = PermissionModel( + id=permission.id, + key=permission.key, + resource_id=resource.id, + resource=permission.resource, + action=permission.action, + description=permission.description, + ) + self._db.add(model) + await self._db.flush() + return self._permission_from_model(model) + + async def get_permission(self, permission_id: UUID) -> Permission | None: + result = await self._db.execute( + select(PermissionModel).where(PermissionModel.id == permission_id) + ) + model = result.scalar_one_or_none() + if model is None: + return None + return self._permission_from_model(model) + + async def list_permissions(self) -> list[Permission]: + result = await self._db.execute(select(PermissionModel)) + return [self._permission_from_model(model) for model in result.scalars().all()] + + async def list_permissions_cursor( + self, + cursor_created_at: datetime | None = None, + cursor_id: UUID | None = None, + limit: int = 10, + direction: CursorDirection = CursorDirection.DIRECTION_NEXT, + ) -> tuple[list[Permission], bool]: + query = select(PermissionModel) + query = self._apply_cursor_pagination( + query, + PermissionModel, + cursor_created_at, + cursor_id, + direction, + ).limit(limit + 1) + + result = await self._db.execute(query) + models = list(result.scalars().all()) + has_more = len(models) > limit + models = models[:limit] + + if direction == CursorDirection.DIRECTION_PREV: + models = list(reversed(models)) + + return [self._permission_from_model(model) for model in models], has_more + + async def update_permission(self, permission: Permission) -> Permission | None: + result = await self._db.execute( + select(PermissionModel).where(PermissionModel.id == permission.id) + ) + model = result.scalar_one_or_none() + if model is None: + return None + + old_key = model.key + resource = await self._get_or_create_resource(permission.resource) + model.key = permission.key + model.resource_id = resource.id + model.resource = permission.resource + model.action = permission.action + model.description = permission.description + await self._db.flush() + + if old_key != permission.key: + await self._rename_permission_policies(old_key, permission.key) + + return self._permission_from_model(model) + + async def delete_permission(self, permission_id: UUID) -> None: + permission = await self.get_permission(permission_id) + if permission is None: + return + + await self._db.execute( + delete(RolePermissionModel).where( + RolePermissionModel.permission_id == permission_id + ) + ) + await self._db.execute( + delete(PermissionModel).where(PermissionModel.id == permission_id) + ) + await self._db.execute( + delete(CasbinRuleModel).where( + CasbinRuleModel.ptype == "p", + CasbinRuleModel.v1 == permission.key, + ) + ) + await self._db.flush() + + async def assign_permission_to_role( + self, + role_id: UUID, + permission_id: UUID, + ) -> None: + role = await self.get_role(role_id) + permission = await self.get_permission(permission_id) + if role is None: + raise ValueError("Role does not exist") + if permission is None: + raise ValueError("Permission does not exist") + + existing = await self._db.execute( + select(RolePermissionModel).where( + RolePermissionModel.role_id == role_id, + RolePermissionModel.permission_id == permission_id, + ) + ) + if existing.scalar_one_or_none() is None: + self._db.add( + RolePermissionModel(role_id=role_id, permission_id=permission_id) + ) + await self._db.flush() + + await self.add_policy("p", role.name, permission.key) + + async def list_role_permissions(self) -> list[tuple[str, str]]: + result = await self._db.execute( + select(RoleModel.name, PermissionModel.key) + .join(RolePermissionModel, RolePermissionModel.role_id == RoleModel.id) + .join( + PermissionModel, PermissionModel.id == RolePermissionModel.permission_id + ) + ) + return [ + (role_name, permission_key) for role_name, permission_key in result.all() + ] + + async def remove_permission_from_role( + self, + role_id: UUID, + permission_id: UUID, + ) -> None: + role = await self.get_role(role_id) + permission = await self.get_permission(permission_id) + if role is None or permission is None: + return + + await self._db.execute( + delete(RolePermissionModel).where( + RolePermissionModel.role_id == role_id, + RolePermissionModel.permission_id == permission_id, + ) + ) + await self.remove_policy("p", role.name, permission.key) + + def _to_policy_line(self, rule: CasbinRuleModel) -> str: + values = [rule.v0, rule.v1, rule.v2, rule.v3, rule.v4, rule.v5] + populated = [value for value in values if value is not None] + return ", ".join([rule.ptype, *populated]) + + def _to_policy_tuple(self, rule: CasbinRuleModel) -> tuple[str, ...]: + values = [rule.v0, rule.v1, rule.v2, rule.v3, rule.v4, rule.v5] + populated = [value for value in values if value is not None] + return (rule.ptype, *populated) + + async def _get_or_create_resource( + self, resource_key: str + ) -> AuthorizationResourceModel: + result = await self._db.execute( + select(AuthorizationResourceModel).where( + AuthorizationResourceModel.key == resource_key, + ) + ) + model = result.scalar_one_or_none() + if model is not None: + return model + + model = AuthorizationResourceModel( + key=resource_key, + name=resource_key.replace("_", " ").title(), + description=f"{resource_key} resources", + ) + self._db.add(model) + await self._db.flush() + return model + + def _value_at(self, values: tuple[str, ...], index: int) -> str | None: + if index >= len(values): + return None + return values[index] + + async def _get_role_by_name(self, role: str) -> RoleModel | None: + result = await self._db.execute(select(RoleModel).where(RoleModel.name == role)) + return result.scalar_one_or_none() + + async def _rename_role_policies(self, old_name: str, new_name: str) -> None: + policy_result = await self._db.execute( + select(CasbinRuleModel).where( + CasbinRuleModel.ptype == "p", + CasbinRuleModel.v0 == old_name, + ) + ) + for rule in policy_result.scalars().all(): + rule.v0 = new_name + + grouping_result = await self._db.execute( + select(CasbinRuleModel).where( + CasbinRuleModel.ptype == "g", + CasbinRuleModel.v1 == old_name, + ) + ) + for rule in grouping_result.scalars().all(): + rule.v1 = new_name + + await self._db.flush() + + async def _rename_permission_policies(self, old_key: str, new_key: str) -> None: + result = await self._db.execute( + select(CasbinRuleModel).where( + CasbinRuleModel.ptype == "p", + CasbinRuleModel.v1 == old_key, + ) + ) + for rule in result.scalars().all(): + rule.v1 = new_key + await self._db.flush() + + def _role_from_model(self, model: RoleModel) -> Role: + return Role( + id=model.id, + name=model.name, + description=model.description, + created_at=model.created_at.isoformat(), + updated_at=model.updated_at.isoformat(), + ) + + def _resource_from_model( + self, + model: AuthorizationResourceModel, + ) -> AuthorizationResource: + return AuthorizationResource( + id=model.id, + key=model.key, + name=model.name, + description=model.description, + ) + + def _permission_from_model(self, model: PermissionModel) -> Permission: + return Permission( + id=model.id, + key=model.key, + resource=model.resource, + action=model.action, + description=model.description, + created_at=model.created_at.isoformat(), + updated_at=model.updated_at.isoformat(), + ) + + def _apply_cursor_pagination( + self, + query, + model, + cursor_created_at: datetime | None, + cursor_id: UUID | None, + direction: CursorDirection, + ): + if cursor_created_at and cursor_id: + if direction == CursorDirection.DIRECTION_NEXT: + query = query.where( + or_( + model.created_at < cursor_created_at, + and_( + model.created_at == cursor_created_at, + model.id < cursor_id, + ), + ) + ) + return query.order_by(model.created_at.desc(), model.id.desc()) + + query = query.where( + or_( + model.created_at > cursor_created_at, + and_( + model.created_at == cursor_created_at, + model.id > cursor_id, + ), + ) + ) + return query.order_by(model.created_at.asc(), model.id.asc()) + + return query.order_by(model.created_at.desc(), model.id.desc()) diff --git a/src/core/authorization/infrastructure/services/__init__.py b/src/core/authorization/infrastructure/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/authorization/infrastructure/services/casbin_authorization_service.py b/src/core/authorization/infrastructure/services/casbin_authorization_service.py new file mode 100644 index 0000000..ca52751 --- /dev/null +++ b/src/core/authorization/infrastructure/services/casbin_authorization_service.py @@ -0,0 +1,137 @@ +from datetime import datetime +from uuid import UUID + +from src.modules.authorization.domain.permissions import permission_key +from src.modules.authorization.domain.services.authorization_service import ( + AuthorizationService, +) +from src.modules.authorization.infrastructure.repositories.casbin_policy_repository import ( + SQLAlchemyCasbinPolicyRepository, +) +from src.modules.authorization.domain.entities.permission import Permission +from src.modules.authorization.domain.entities.role import Role +from src.shared.utils.cursor import CursorDirection + +CASBIN_MODEL_TEXT = """ +[request_definition] +r = sub, perm + +[policy_definition] +p = sub, perm + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && (p.perm == "*" || r.perm == p.perm) +""" + + +class CasbinAuthorizationService(AuthorizationService): + def __init__(self, policy_repository: SQLAlchemyCasbinPolicyRepository): + self._policy_repository = policy_repository + + async def can(self, subject: str, resource: str, action: str) -> bool: + enforcer = await self._build_enforcer() + return bool(enforcer.enforce(subject, permission_key(resource, action))) + + async def assign_role(self, subject: str, role: str) -> None: + await self._policy_repository.assign_role(subject, role) + + async def get_roles_for_subject(self, subject: str) -> list[str]: + return await self._policy_repository.get_roles_for_subject(subject) + + async def create_role(self, role: Role) -> Role: + return await self._policy_repository.create_role(role) + + async def update_role(self, role: Role) -> Role | None: + return await self._policy_repository.update_role(role) + + async def delete_role(self, role_id: UUID) -> None: + await self._policy_repository.delete_role(role_id) + + async def get_role(self, role_id: UUID) -> Role | None: + return await self._policy_repository.get_role(role_id) + + async def list_roles(self) -> list[Role]: + return await self._policy_repository.list_roles() + + async def list_roles_cursor( + self, + cursor_created_at: datetime | None = None, + cursor_id: UUID | None = None, + limit: int = 10, + direction: CursorDirection = CursorDirection.DIRECTION_NEXT, + ) -> tuple[list[Role], bool]: + return await self._policy_repository.list_roles_cursor( + cursor_created_at=cursor_created_at, + cursor_id=cursor_id, + limit=limit, + direction=direction, + ) + + async def create_permission(self, permission: Permission) -> Permission: + return await self._policy_repository.create_permission(permission) + + async def update_permission(self, permission: Permission) -> Permission | None: + return await self._policy_repository.update_permission(permission) + + async def delete_permission(self, permission_id: UUID) -> None: + await self._policy_repository.delete_permission(permission_id) + + async def get_permission(self, permission_id: UUID) -> Permission | None: + return await self._policy_repository.get_permission(permission_id) + + async def list_permissions(self) -> list[Permission]: + return await self._policy_repository.list_permissions() + + async def list_permissions_cursor( + self, + cursor_created_at: datetime | None = None, + cursor_id: UUID | None = None, + limit: int = 10, + direction: CursorDirection = CursorDirection.DIRECTION_NEXT, + ) -> tuple[list[Permission], bool]: + return await self._policy_repository.list_permissions_cursor( + cursor_created_at=cursor_created_at, + cursor_id=cursor_id, + limit=limit, + direction=direction, + ) + + async def assign_permission_to_role( + self, + role_id: UUID, + permission_id: UUID, + ) -> None: + await self._policy_repository.assign_permission_to_role(role_id, permission_id) + + async def remove_permission_from_role( + self, + role_id: UUID, + permission_id: UUID, + ) -> None: + await self._policy_repository.remove_permission_from_role( + role_id, + permission_id, + ) + + async def _build_enforcer(self): + try: + import casbin + from casbin import persist + except ModuleNotFoundError as exc: + raise RuntimeError( + "Casbin is not installed. Run `poetry install` to install project dependencies." + ) from exc + + model = casbin.Model() + model.load_model_from_text(CASBIN_MODEL_TEXT) + enforcer = casbin.Enforcer(model) + for policy_line in await self._policy_repository.load_policy_lines(): + persist.load_policy_line(policy_line, enforcer.model) + enforcer.build_role_links() + return enforcer diff --git a/src/modules/authorization/application/__init__.py b/src/modules/authorization/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/create_permission/__init__.py b/src/modules/authorization/application/create_permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/create_role/__init__.py b/src/modules/authorization/application/create_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/delete_permission/__init__.py b/src/modules/authorization/application/delete_permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/delete_role/__init__.py b/src/modules/authorization/application/delete_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/get_permission/__init__.py b/src/modules/authorization/application/get_permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/get_role/__init__.py b/src/modules/authorization/application/get_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/list_permissions/__init__.py b/src/modules/authorization/application/list_permissions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/list_roles/__init__.py b/src/modules/authorization/application/list_roles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/update_permission/__init__.py b/src/modules/authorization/application/update_permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/application/update_role/__init__.py b/src/modules/authorization/application/update_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/domain/__init__.py b/src/modules/authorization/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/domain/entities/__init__.py b/src/modules/authorization/domain/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/domain/services/__init__.py b/src/modules/authorization/domain/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/infrastructure/__init__.py b/src/modules/authorization/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/infrastructure/models/__init__.py b/src/modules/authorization/infrastructure/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/infrastructure/repositories/__init__.py b/src/modules/authorization/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/infrastructure/services/__init__.py b/src/modules/authorization/infrastructure/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/presentation/__init__.py b/src/modules/authorization/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/presentation/routers/__init__.py b/src/modules/authorization/presentation/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/authorization/presentation/schema/__init__.py b/src/modules/authorization/presentation/schema/__init__.py new file mode 100644 index 0000000..e69de29