diff --git a/backend/app/admin/api/v1/sys/__init__.py b/backend/app/admin/api/v1/sys/__init__.py index 26d1a475..4855956a 100644 --- a/backend/app/admin/api/v1/sys/__init__.py +++ b/backend/app/admin/api/v1/sys/__init__.py @@ -2,8 +2,6 @@ # -*- coding: utf-8 -*- from fastapi import APIRouter -from backend.app.admin.api.v1.sys.api import router as api_router -from backend.app.admin.api.v1.sys.casbin import router as casbin_router from backend.app.admin.api.v1.sys.config import router as config_router from backend.app.admin.api.v1.sys.data_rule import router as data_rule_router from backend.app.admin.api.v1.sys.dept import router as dept_router @@ -16,8 +14,6 @@ from backend.app.admin.api.v1.sys.user import router as user_router router = APIRouter(prefix='/sys') -router.include_router(api_router, prefix='/apis', tags=['系统API']) -router.include_router(casbin_router, prefix='/casbin', tags=['系统Casbin权限']) router.include_router(config_router, prefix='/configs', tags=['系统配置']) router.include_router(dept_router, prefix='/depts', tags=['系统部门']) router.include_router(dict_data_router, prefix='/dict-datas', tags=['系统字典数据']) diff --git a/backend/app/admin/model/__init__.py b/backend/app/admin/model/__init__.py index 53cf5fca..2e7ff526 100644 --- a/backend/app/admin/model/__init__.py +++ b/backend/app/admin/model/__init__.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from backend.app.admin.model.api import Api -from backend.app.admin.model.casbin_rule import CasbinRule from backend.app.admin.model.config import Config from backend.app.admin.model.data_rule import DataRule from backend.app.admin.model.dept import Dept diff --git a/backend/common/security/permission.py b/backend/common/security/permission.py index 3b25e512..26d7fb1f 100644 --- a/backend/common/security/permission.py +++ b/backend/common/security/permission.py @@ -28,7 +28,7 @@ class RequestPermission: self.value = value async def __call__(self, request: Request): - if settings.PERMISSION_MODE == 'role-menu': + if settings.RBAC_ROLE_MENU_MODE: if not isinstance(self.value, str): raise ServerError # 附加权限标识 diff --git a/backend/common/security/rbac.py b/backend/common/security/rbac.py index 07d24e59..d34c7388 100644 --- a/backend/common/security/rbac.py +++ b/backend/common/security/rbac.py @@ -1,119 +1,74 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import casbin -import casbin_async_sqlalchemy_adapter - from fastapi import Depends, Request -from backend.app.admin.model import CasbinRule from backend.common.enums import MethodType, StatusType from backend.common.exception.errors import AuthorizationError, TokenError from backend.common.security.jwt import DependsJwtAuth from backend.core.conf import settings -from backend.database.db import async_engine +from backend.plugin.casbin.utils.rbac import casbin_verify -class RBAC: - @staticmethod - async def enforcer() -> casbin.AsyncEnforcer: - """ - 获取 casbin 执行器 +async def rbac_verify(request: Request, _token: str = DependsJwtAuth) -> None: + """ + RBAC 权限校验(鉴权顺序很重要,谨慎修改) - :return: - """ - # 模型定义:https://casbin.org/zh/docs/category/model - _CASBIN_RBAC_MODEL_CONF_TEXT = """ - [request_definition] - r = sub, obj, act + :param request: + :param _token: + :return: + """ + path = request.url.path - [policy_definition] - p = sub, obj, act + # API 鉴权白名单 + if path in settings.TOKEN_REQUEST_PATH_EXCLUDE: + return - [role_definition] - g = _, _ + # JWT 授权状态强制校验 + if not request.auth.scopes: + raise TokenError - [policy_effect] - e = some(where (p.eft == allow)) + # 超级管理员免校验 + if request.user.is_superuser: + return - [matchers] - m = g(r.sub, p.sub) && (keyMatch(r.obj, p.obj) || keyMatch3(r.obj, p.obj)) && (r.act == p.act || p.act == "*") - """ - adapter = casbin_async_sqlalchemy_adapter.Adapter(async_engine, db_class=CasbinRule) - model = casbin.AsyncEnforcer.new_model(text=_CASBIN_RBAC_MODEL_CONF_TEXT) - enforcer = casbin.AsyncEnforcer(model, adapter) - await enforcer.load_policy() - return enforcer + # 检测用户角色 + user_roles = request.user.roles + if not user_roles or all(status == 0 for status in user_roles): + raise AuthorizationError(msg='用户未分配角色,请联系系统管理员') - async def rbac_verify(self, request: Request, _token: str = DependsJwtAuth) -> None: - """ - RBAC 权限校验(鉴权顺序很重要,谨慎修改) + # 检测用户所属角色菜单 + if not any(len(role.menus) > 0 for role in user_roles): + raise AuthorizationError(msg='用户未分配菜单,请联系系统管理员') - :param request: - :param _token: - :return: - """ - path = request.url.path + # 检测后台管理操作权限 + method = request.method + if method != MethodType.GET or method != MethodType.OPTIONS: + if not request.user.is_staff: + raise AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员') - # API 鉴权白名单 - if path in settings.TOKEN_REQUEST_PATH_EXCLUDE: + # RBAC 鉴权 + if settings.RBAC_ROLE_MENU_MODE: + path_auth_perm = getattr(request.state, 'permission', None) + + # 没有菜单操作权限标识不校验 + if not path_auth_perm: return - # JWT 授权状态强制校验 - if not request.auth.scopes: - raise TokenError - - # 超级管理员免校验 - if request.user.is_superuser: + # 菜单鉴权白名单 + if path_auth_perm in settings.RBAC_ROLE_MENU_EXCLUDE: return - # 检测用户角色 - user_roles = request.user.roles - if not user_roles or all(status == 0 for status in user_roles): - raise AuthorizationError(msg='用户未分配角色,请联系系统管理员') - - # 检测用户所属角色菜单 - if not any(len(role.menus) > 0 for role in user_roles): - raise AuthorizationError(msg='用户未分配菜单,请联系系统管理员') - - # 检测后台管理操作权限 - method = request.method - if method != MethodType.GET or method != MethodType.OPTIONS: - if not request.user.is_staff: - raise AuthorizationError(msg='用户已被禁止后台管理操作,请联系系统管理员') - - # RBAC 鉴权 - if settings.PERMISSION_MODE == 'role-menu': - path_auth_perm = getattr(request.state, 'permission', None) - - # 没有菜单操作权限标识不校验 - if not path_auth_perm: - return - - # 菜单鉴权白名单 - if path_auth_perm in settings.RBAC_ROLE_MENU_EXCLUDE: - return - - # 已分配菜单权限校验 - allow_perms = [] - for role in user_roles: - for menu in role.menus: - if menu.perms and menu.status == StatusType.enable: - allow_perms.extend(menu.perms.split(',')) - if path_auth_perm not in allow_perms: - raise AuthorizationError - else: - # casbin 鉴权白名单 - if (method, path) in settings.RBAC_CASBIN_EXCLUDE: - return - - # casbin 权限校验 - # 实现机制:backend/app/admin/api/v1/sys/casbin.py - user_uuid = request.user.uuid - enforcer = await self.enforcer() - if not enforcer.enforce(user_uuid, path, method): - raise AuthorizationError + # 已分配菜单权限校验 + allow_perms = [] + for role in user_roles: + for menu in role.menus: + if menu.perms and menu.status == StatusType.enable: + allow_perms.extend(menu.perms.split(',')) + if path_auth_perm not in allow_perms: + raise AuthorizationError + else: + await casbin_verify(request) -rbac: RBAC = RBAC() # RBAC 授权依赖注入 -DependsRBAC = Depends(rbac.rbac_verify) +DependsRBAC = Depends(rbac_verify) diff --git a/backend/core/conf.py b/backend/core/conf.py index 7066e9ff..165c23fb 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -75,18 +75,8 @@ class Settings(BaseSettings): JWT_USER_REDIS_PREFIX: str = 'fba:user' JWT_USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 - # Permission (RBAC) - PERMISSION_MODE: Literal['casbin', 'role-menu'] = 'casbin' - PERMISSION_REDIS_PREFIX: str = 'fba:permission' - # RBAC - # Casbin - RBAC_CASBIN_EXCLUDE: set[tuple[str, str]] = { - ('POST', f'{FASTAPI_API_V1_PATH}/auth/logout'), - ('POST', f'{FASTAPI_API_V1_PATH}/auth/token/new'), - } - - # Role-Menu + RBAC_ROLE_MENU_MODE: bool = False RBAC_ROLE_MENU_EXCLUDE: list[str] = [ 'sys:monitor:redis', 'sys:monitor:server', diff --git a/backend/plugin/casbin/__init__.py b/backend/plugin/casbin/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/casbin/api/__init__.py b/backend/plugin/casbin/api/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/api/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/casbin/api/v1/__init__.py b/backend/plugin/casbin/api/v1/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/api/v1/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/casbin/api/v1/sys/__init__.py b/backend/plugin/casbin/api/v1/sys/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/api/v1/sys/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/app/admin/api/v1/sys/api.py b/backend/plugin/casbin/api/v1/sys/api.py similarity index 94% rename from backend/app/admin/api/v1/sys/api.py rename to backend/plugin/casbin/api/v1/sys/api.py index 6acc691b..6f0ce1bd 100644 --- a/backend/app/admin/api/v1/sys/api.py +++ b/backend/plugin/casbin/api/v1/sys/api.py @@ -4,14 +4,14 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query, Request -from backend.app.admin.schema.api import CreateApiParam, GetApiDetail, UpdateApiParam -from backend.app.admin.service.api_service import api_service from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth from backend.common.security.permission import RequestPermission from backend.common.security.rbac import DependsRBAC from backend.database.db import CurrentSession +from backend.plugin.casbin.schema.api import CreateApiParam, GetApiDetail, UpdateApiParam +from backend.plugin.casbin.service.api_service import api_service router = APIRouter() diff --git a/backend/app/admin/api/v1/sys/casbin.py b/backend/plugin/casbin/api/v1/sys/casbin.py similarity index 98% rename from backend/app/admin/api/v1/sys/casbin.py rename to backend/plugin/casbin/api/v1/sys/casbin.py index 2fa59873..e5929c91 100644 --- a/backend/app/admin/api/v1/sys/casbin.py +++ b/backend/plugin/casbin/api/v1/sys/casbin.py @@ -5,7 +5,13 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query -from backend.app.admin.schema.casbin_rule import ( +from backend.common.pagination import DependsPagination, PageData, paging_data +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.jwt import DependsJwtAuth +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.database.db import CurrentSession +from backend.plugin.casbin.schema.casbin_rule import ( CreatePolicyParam, CreateUserRoleParam, DeleteAllPoliciesParam, @@ -15,13 +21,7 @@ from backend.app.admin.schema.casbin_rule import ( UpdatePoliciesParam, UpdatePolicyParam, ) -from backend.app.admin.service.casbin_service import casbin_service -from backend.common.pagination import DependsPagination, PageData, paging_data -from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base -from backend.common.security.jwt import DependsJwtAuth -from backend.common.security.permission import RequestPermission -from backend.common.security.rbac import DependsRBAC -from backend.database.db import CurrentSession +from backend.plugin.casbin.service.casbin_service import casbin_service router = APIRouter() diff --git a/backend/plugin/casbin/conf.py b/backend/plugin/casbin/conf.py new file mode 100644 index 00000000..e29837b9 --- /dev/null +++ b/backend/plugin/casbin/conf.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from functools import lru_cache + +from pydantic_settings import BaseSettings + +from backend.core.conf import settings + + +class CasbinSettings(BaseSettings): + """Casbin Settings""" + + # RBAC + RBAC_CASBIN_EXCLUDE: set[tuple[str, str]] = { + ('POST', f'{settings.FASTAPI_API_V1_PATH}/auth/logout'), + ('POST', f'{settings.FASTAPI_API_V1_PATH}/auth/token/new'), + } + + +@lru_cache +def get_casbin_settings() -> CasbinSettings: + """获取 xxx 配置""" + return CasbinSettings() + + +casbin_settings = get_casbin_settings() diff --git a/backend/plugin/casbin/crud/__init__.py b/backend/plugin/casbin/crud/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/crud/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/app/admin/crud/crud_api.py b/backend/plugin/casbin/crud/crud_api.py similarity index 95% rename from backend/app/admin/crud/crud_api.py rename to backend/plugin/casbin/crud/crud_api.py index 12050253..8112de5a 100644 --- a/backend/app/admin/crud/crud_api.py +++ b/backend/plugin/casbin/crud/crud_api.py @@ -7,9 +7,9 @@ from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy_crud_plus import CRUDPlus -from backend.app.admin.model import Api -from backend.app.admin.schema.api import CreateApiParam, UpdateApiParam from backend.common.security.permission import filter_data_permission +from backend.plugin.casbin.model import Api +from backend.plugin.casbin.schema.api import CreateApiParam, UpdateApiParam class CRUDApi(CRUDPlus[Api]): diff --git a/backend/app/admin/crud/crud_casbin.py b/backend/plugin/casbin/crud/crud_casbin.py similarity index 90% rename from backend/app/admin/crud/crud_casbin.py rename to backend/plugin/casbin/crud/crud_casbin.py index 26b38093..5af90754 100644 --- a/backend/app/admin/crud/crud_casbin.py +++ b/backend/plugin/casbin/crud/crud_casbin.py @@ -6,8 +6,8 @@ from sqlalchemy import Select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy_crud_plus import CRUDPlus -from backend.app.admin.model import CasbinRule -from backend.app.admin.schema.casbin_rule import DeleteAllPoliciesParam +from backend.plugin.casbin.model import CasbinRule +from backend.plugin.casbin.schema.casbin_rule import DeleteAllPoliciesParam class CRUDCasbin(CRUDPlus[CasbinRule]): diff --git a/backend/plugin/casbin/model/__init__.py b/backend/plugin/casbin/model/__init__.py new file mode 100644 index 00000000..62f1f2c8 --- /dev/null +++ b/backend/plugin/casbin/model/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from backend.plugin.casbin.model.api import Api +from backend.plugin.casbin.model.casbin_rule import CasbinRule diff --git a/backend/app/admin/model/api.py b/backend/plugin/casbin/model/api.py similarity index 100% rename from backend/app/admin/model/api.py rename to backend/plugin/casbin/model/api.py diff --git a/backend/app/admin/model/casbin_rule.py b/backend/plugin/casbin/model/casbin_rule.py similarity index 100% rename from backend/app/admin/model/casbin_rule.py rename to backend/plugin/casbin/model/casbin_rule.py diff --git a/backend/plugin/casbin/plugin.toml b/backend/plugin/casbin/plugin.toml new file mode 100644 index 00000000..c499bbe5 --- /dev/null +++ b/backend/plugin/casbin/plugin.toml @@ -0,0 +1,10 @@ +[app] +include = 'admin' + +[api.api] +prefix = '/apis' +tags = '系统API' + +[api.casbin] +prefix = '/casbin' +tags = '系统Casbin权限' diff --git a/backend/plugin/casbin/requirements.txt b/backend/plugin/casbin/requirements.txt new file mode 100644 index 00000000..ac058541 --- /dev/null +++ b/backend/plugin/casbin/requirements.txt @@ -0,0 +1,2 @@ +casbin>=1.38.0 +casbin_async_sqlalchemy_adapter>=1.7.0 diff --git a/backend/plugin/casbin/schema/__init__.py b/backend/plugin/casbin/schema/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/schema/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/app/admin/schema/api.py b/backend/plugin/casbin/schema/api.py similarity index 100% rename from backend/app/admin/schema/api.py rename to backend/plugin/casbin/schema/api.py diff --git a/backend/app/admin/schema/casbin_rule.py b/backend/plugin/casbin/schema/casbin_rule.py similarity index 100% rename from backend/app/admin/schema/casbin_rule.py rename to backend/plugin/casbin/schema/casbin_rule.py diff --git a/backend/plugin/casbin/service/__init__.py b/backend/plugin/casbin/service/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/service/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/app/admin/service/api_service.py b/backend/plugin/casbin/service/api_service.py similarity index 91% rename from backend/app/admin/service/api_service.py rename to backend/plugin/casbin/service/api_service.py index f16a8f92..6cdfc207 100644 --- a/backend/app/admin/service/api_service.py +++ b/backend/plugin/casbin/service/api_service.py @@ -5,11 +5,11 @@ from typing import Sequence from fastapi import Request from sqlalchemy import Select -from backend.app.admin.crud.crud_api import api_dao -from backend.app.admin.model import Api -from backend.app.admin.schema.api import CreateApiParam, UpdateApiParam from backend.common.exception import errors from backend.database.db import async_db_session +from backend.plugin.casbin.crud.crud_api import api_dao +from backend.plugin.casbin.model import Api +from backend.plugin.casbin.schema.api import CreateApiParam, UpdateApiParam class ApiService: diff --git a/backend/app/admin/service/casbin_service.py b/backend/plugin/casbin/service/casbin_service.py similarity index 86% rename from backend/app/admin/service/casbin_service.py rename to backend/plugin/casbin/service/casbin_service.py index 4a083519..f93670bb 100644 --- a/backend/app/admin/service/casbin_service.py +++ b/backend/plugin/casbin/service/casbin_service.py @@ -4,8 +4,10 @@ from uuid import UUID from sqlalchemy import Select -from backend.app.admin.crud.crud_casbin import casbin_dao -from backend.app.admin.schema.casbin_rule import ( +from backend.common.exception import errors +from backend.database.db import async_db_session +from backend.plugin.casbin.crud.crud_casbin import casbin_dao +from backend.plugin.casbin.schema.casbin_rule import ( CreatePolicyParam, CreateUserRoleParam, DeleteAllPoliciesParam, @@ -14,9 +16,7 @@ from backend.app.admin.schema.casbin_rule import ( UpdatePoliciesParam, UpdatePolicyParam, ) -from backend.common.exception import errors -from backend.common.security.rbac import rbac -from backend.database.db import async_db_session +from backend.plugin.casbin.utils.rbac import casbin_enforcer class CasbinService: @@ -26,7 +26,7 @@ class CasbinService: @staticmethod async def get_policy_list(*, role: int | None = None) -> list: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() if role is not None: data = enforcer.get_filtered_named_policy('p', 0, str(role)) else: @@ -35,7 +35,7 @@ class CasbinService: @staticmethod async def create_policy(*, p: CreatePolicyParam) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.add_policy(p.sub, p.path, p.method) if not data: raise errors.ForbiddenError(msg='权限已存在') @@ -43,7 +43,7 @@ class CasbinService: @staticmethod async def create_policies(*, ps: list[CreatePolicyParam]) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.add_policies([list(p.model_dump().values()) for p in ps]) if not data: raise errors.ForbiddenError(msg='权限已存在') @@ -53,7 +53,7 @@ class CasbinService: async def update_policy(*, obj: UpdatePolicyParam) -> bool: old_obj = obj.old new_obj = obj.new - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() _p = enforcer.has_policy(old_obj.sub, old_obj.path, old_obj.method) if not _p: raise errors.NotFoundError(msg='权限不存在') @@ -64,7 +64,7 @@ class CasbinService: @staticmethod async def update_policies(*, obj: UpdatePoliciesParam) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.update_policies( [list(o.model_dump().values()) for o in obj.old], [list(n.model_dump().values()) for n in obj.new] ) @@ -72,7 +72,7 @@ class CasbinService: @staticmethod async def delete_policy(*, p: DeletePolicyParam) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() _p = enforcer.has_policy(p.sub, p.path, p.method) if not _p: raise errors.NotFoundError(msg='权限不存在') @@ -81,7 +81,7 @@ class CasbinService: @staticmethod async def delete_policies(*, ps: list[DeletePolicyParam]) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.remove_policies([list(p.model_dump().values()) for p in ps]) if not data: raise errors.NotFoundError(msg='权限不存在') @@ -95,13 +95,13 @@ class CasbinService: @staticmethod async def get_group_list() -> list: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = enforcer.get_grouping_policy() return data @staticmethod async def create_group(*, g: CreateUserRoleParam) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.add_grouping_policy(g.uuid, g.role) if not data: raise errors.ForbiddenError(msg='权限已存在') @@ -109,7 +109,7 @@ class CasbinService: @staticmethod async def create_groups(*, gs: list[CreateUserRoleParam]) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.add_grouping_policies([list(g.model_dump().values()) for g in gs]) if not data: raise errors.ForbiddenError(msg='权限已存在') @@ -117,7 +117,7 @@ class CasbinService: @staticmethod async def delete_group(*, g: DeleteUserRoleParam) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() _g = enforcer.has_grouping_policy(g.uuid, g.role) if not _g: raise errors.NotFoundError(msg='权限不存在') @@ -126,7 +126,7 @@ class CasbinService: @staticmethod async def delete_groups(*, gs: list[DeleteUserRoleParam]) -> bool: - enforcer = await rbac.enforcer() + enforcer = await casbin_enforcer() data = await enforcer.remove_grouping_policies([list(g.model_dump().values()) for g in gs]) if not data: raise errors.NotFoundError(msg='权限不存在') diff --git a/backend/plugin/casbin/utils/__init__.py b/backend/plugin/casbin/utils/__init__.py new file mode 100644 index 00000000..56fafa58 --- /dev/null +++ b/backend/plugin/casbin/utils/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/backend/plugin/casbin/utils/rbac.py b/backend/plugin/casbin/utils/rbac.py new file mode 100644 index 00000000..2932d7da --- /dev/null +++ b/backend/plugin/casbin/utils/rbac.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import casbin +import casbin_async_sqlalchemy_adapter + +from fastapi import Request + +from backend.common.exception.errors import AuthorizationError +from backend.database.db import async_engine +from backend.plugin.casbin.conf import casbin_settings +from backend.plugin.casbin.model import CasbinRule + + +async def casbin_enforcer() -> casbin.AsyncEnforcer: + """ + 获取 casbin 执行器 + + :return: + """ + # 模型定义:https://casbin.org/zh/docs/category/model + _CASBIN_RBAC_MODEL_CONF_TEXT = """ + [request_definition] + r = sub, obj, act + + [policy_definition] + p = sub, obj, act + + [role_definition] + g = _, _ + + [policy_effect] + e = some(where (p.eft == allow)) + + [matchers] + m = g(r.sub, p.sub) && (keyMatch(r.obj, p.obj) || keyMatch3(r.obj, p.obj)) && (r.act == p.act || p.act == "*") + """ + adapter = casbin_async_sqlalchemy_adapter.Adapter(async_engine, db_class=CasbinRule) + model = casbin.AsyncEnforcer.new_model(text=_CASBIN_RBAC_MODEL_CONF_TEXT) + enforcer = casbin.AsyncEnforcer(model, adapter) + await enforcer.load_policy() + return enforcer + + +async def casbin_verify(request: Request) -> None: + """ + Casbin 权限校验 + + :param request: + :return: + """ + method = request.method + path = request.url.path + + # casbin 鉴权白名单 + if (method, path) in casbin_settings.RBAC_CASBIN_EXCLUDE: + return + + # casbin 权限校验 + user_uuid = request.user.uuid + enforcer = await casbin_enforcer() + if not enforcer.enforce(user_uuid, path, method): + raise AuthorizationError diff --git a/backend/plugin/notice/plugin.toml b/backend/plugin/notice/plugin.toml index b13e1326..79fc97c7 100644 --- a/backend/plugin/notice/plugin.toml +++ b/backend/plugin/notice/plugin.toml @@ -1,8 +1,6 @@ -# 属于哪个 app,如果为独立 app,应设置为 '' -app = 'admin' +[app] +include = 'admin' -# api 路由配置,仅对于非独立 app 可用 -[api] -# prefix 必须带前导 / +[api.notice] prefix = '/notices' tags = '系统通知公告' diff --git a/backend/plugin/tools.py b/backend/plugin/tools.py index 53a2eb77..eb8205de 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/tools.py @@ -17,6 +17,10 @@ from backend.core.path_conf import PLUGIN_DIR from backend.utils.import_parse import import_module_cached +class PluginInjectError(Exception): + pass + + def get_plugins() -> list[str]: """获取插件""" plugin_packages = [] @@ -54,76 +58,92 @@ def plugin_router_inject() -> None: for plugin in plugins: toml_path = os.path.join(PLUGIN_DIR, plugin, 'plugin.toml') if not os.path.exists(toml_path): - raise FileNotFoundError(f'插件 {plugin} 缺少 plugin.toml 配置文件,请检查插件是否合法') + raise PluginInjectError(f'插件 {plugin} 缺少 plugin.toml 配置文件,请检查插件是否合法') - # 解析 plugin.toml + # 获取 plugin.toml 配置 with open(toml_path, 'r', encoding='utf-8') as f: data = rtoml.load(f) - app_name = data.get('app', '') - prefix = data.get('api', {}).get('prefix', '') - tags = data.get('api', {}).get('tags', []) + api = data.get('api', {}) - # 插件中 API 路由文件的路径 - plugin_api_path = os.path.join(PLUGIN_DIR, plugin, 'api') - if not os.path.exists(plugin_api_path): - raise FileNotFoundError(f'插件 {plugin} 缺少 api 目录,请检查插件文件是否完整') + # 非独立 app + if api: + app_include = data.get('app', {}).get('include', '') + if not app_include: + raise PluginInjectError(f'非独立 app 插件 {plugin} 配置文件存在错误,请检查') - # 路由注入 - if app_name: - # 非独立应用:将插件路由注入到对应模块的路由中 + # 插件中 API 路由文件的路径 + plugin_api_path = os.path.join(PLUGIN_DIR, plugin, 'api') + if not os.path.exists(plugin_api_path): + raise PluginInjectError(f'插件 {plugin} 缺少 api 目录,请检查插件文件是否完整') + + # 将插件路由注入到对应模块的路由中 for root, _, api_files in os.walk(plugin_api_path): for file in api_files: if file.endswith('.py') and file != '__init__.py': - file_path = os.path.join(root, file) + # 解析插件路由配置 + prefix = data.get('api', {}).get(f'{file[:-3]}', {}).get('prefix', '') + tags = data.get('api', {}).get(f'{file[:-3]}', {}).get('tags', []) # 获取插件路由模块 + file_path = os.path.join(root, file) path_to_module_str = os.path.relpath(file_path, PLUGIN_DIR).replace(os.sep, '.')[:-3] module_path = f'backend.plugin.{path_to_module_str}' try: module = import_module_cached(module_path) - except ImportError as e: - raise ImportError(f'导入模块 {module_path} 失败:{e}') from e + except PluginInjectError as e: + raise PluginInjectError(f'导入非独立 app 插件 {plugin} 模块 {module_path} 失败:{e}') from e plugin_router = getattr(module, 'router', None) if not plugin_router: warnings.warn( - f'目标模块 {module_path} 中没有有效的 router,请检查插件文件是否完整', + f'非独立 app 插件 {plugin} 模块 {module_path} 中没有有效的 router,' + '请检查插件文件是否完整', FutureWarning, ) continue # 获取源程序路由模块 relative_path = os.path.relpath(root, plugin_api_path) - target_module_path = f'backend.app.{app_name}.api.{relative_path.replace(os.sep, ".")}' + target_module_path = f'backend.app.{app_include}.api.{relative_path.replace(os.sep, ".")}' try: target_module = import_module_cached(target_module_path) - except ImportError as e: - raise ImportError(f'导入目标模块 {target_module_path} 失败:{e}') from e + except PluginInjectError as e: + raise PluginInjectError(f'导入源程序模块 {target_module_path} 失败:{e}') from e target_router = getattr(target_module, 'router', None) if not target_router or not isinstance(target_router, APIRouter): - raise AttributeError(f'目标模块 {module_path} 中没有有效的 router,请检查插件文件是否完整') + raise PluginInjectError( + f'非独立 app 插件 {plugin} 模块 {module_path} 中没有有效的 router,' + '请检查插件文件是否完整' + ) # 将插件路由注入到目标 router 中 target_router.include_router( router=plugin_router, prefix=prefix, - tags=tags if tags == [] else [tags], + tags=[tags] if tags else [], ) + # 独立 app else: - # 独立应用:将插件中的路由直接注入到总路由中 + # 将插件中的路由直接注入到总路由中 module_path = f'backend.plugin.{plugin}.api.router' try: module = import_module_cached(module_path) - except ImportError as e: - raise ImportError(f'导入目标模块 {module_path} 失败:{e}') from e - plugin_router = getattr(module, 'router', None) - if not plugin_router or not isinstance(plugin_router, APIRouter): - raise AttributeError(f'目标模块 {module_path} 中没有有效的 router,请检查插件文件是否完整') - target_module_path = 'backend.app.router' - target_module = import_module_cached(target_module_path) - target_router = getattr(target_module, 'router') + except PluginInjectError as e: + raise PluginInjectError(f'导入独立 app 插件 {plugin} 模块 {module_path} 失败:{e}') from e + routers = data.get('app', {}).get('router', []) + if not routers or not isinstance(routers, list): + raise PluginInjectError(f'独立 app 插件 {plugin} 配置文件存在错误,请检查') + for router in routers: + plugin_router = getattr(module, router, None) + if not plugin_router or not isinstance(plugin_router, APIRouter): + raise PluginInjectError( + f'独立 app 插件 {plugin} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整' + ) + target_module_path = 'backend.app.router' + target_module = import_module_cached(target_module_path) + target_router = getattr(target_module, 'router') - # 将插件路由注入到目标 router 中 - target_router.include_router(plugin_router) + # 将插件路由注入到目标 router 中 + target_router.include_router(plugin_router) def install_requirements() -> None: @@ -138,7 +158,7 @@ def install_requirements() -> None: subprocess.run([sys.executable, '-m', 'ensurepip', '--upgrade']) subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-r', requirements_file]) except subprocess.CalledProcessError as e: - raise EnvironmentError(f'插件 {plugin} 依赖安装失败:{e}') from e + raise PluginInjectError(f'插件 {plugin} 依赖安装失败:{e}') from e async def install_requirements_async() -> None: @@ -162,4 +182,4 @@ async def install_requirements_async() -> None: ) _, stderr = await res.communicate() if res.returncode != 0: - raise EnvironmentError(f'插件 {plugin} 依赖包安装失败:{stderr}') + raise PluginInjectError(f'插件 {plugin} 依赖包安装失败:{stderr}') diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 09649950..afe7cf3a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,8 +19,6 @@ dependencies = [ "asyncmy>=0.2.10", "asyncpg>=0.30.0", "bcrypt>=4.2.1", - "casbin>=1.38.0", - "casbin_async_sqlalchemy_adapter>=1.7.0", "celery==5.3.6", # When celery version < 6.0.0 # https://github.com/celery/celery/issues/7874 diff --git a/backend/requirements.txt b/backend/requirements.txt index db1acf77..bf867d4c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,8 +13,6 @@ asyncpg==0.30.0 bcrypt==4.2.1 bidict==0.23.1 billiard==4.2.1 -casbin==1.38.0 -casbin-async-sqlalchemy-adapter==1.7.0 celery==5.3.6 celery-aio-pool==0.1.0rc8 certifi==2025.1.31 @@ -95,7 +93,6 @@ rtoml==0.12.0 ruff==0.9.5 shellingham==1.5.4 simple-websocket==1.1.0 -simpleeval==1.0.3 six==1.17.0 sniffio==1.3.1 sqlalchemy==2.0.38 diff --git a/backend/uv.lock b/backend/uv.lock index 42c3cc23..d2dd2b49 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -242,31 +242,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766 }, ] -[[package]] -name = "casbin" -version = "1.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "simpleeval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/73/9ed7ce839505b3c82dba0dcb327a45fb0a2497d45c78c54aced75ff12475/casbin-1.38.0.tar.gz", hash = "sha256:64a758b367d829aa69d30ef28f58e6b26511ff238a939ad3f7d053535f7d4627", size = 424744 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/ad/8f4720120c0e3f4d22e24b57f452868a4e07f6c1bb32be5cc548be5e45cb/casbin-1.38.0-py3-none-any.whl", hash = "sha256:229245632a98ec17c53cbaf8432826c3180876b5577fe85b2fa3d708c5d293da", size = 474426 }, -] - -[[package]] -name = "casbin-async-sqlalchemy-adapter" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "casbin" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/32/28/52d18166e54b8f36228212d2ee00ee86b41d492cab18973e324e3523ff36/casbin_async_sqlalchemy_adapter-1.7.0.tar.gz", hash = "sha256:c946bdbb6c1f9a458fc75e86ac5565fd5f0c07704b78d4b57c257b97a06b5c68", size = 10946 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/f3/1fa59284c75e6497d1184459e4743f1ceb39125becca0ca0d67c4f385356/casbin_async_sqlalchemy_adapter-1.7.0-py3-none-any.whl", hash = "sha256:d0a1ede29303a11eaa79e258579536b45267373cbd8f03e047ae1a399b92344b", size = 11293 }, -] - [[package]] name = "celery" version = "5.3.6" @@ -571,8 +546,6 @@ dependencies = [ { name = "asyncmy" }, { name = "asyncpg" }, { name = "bcrypt" }, - { name = "casbin" }, - { name = "casbin-async-sqlalchemy-adapter" }, { name = "celery" }, { name = "celery-aio-pool" }, { name = "cryptography" }, @@ -626,8 +599,6 @@ requires-dist = [ { name = "asyncmy", specifier = ">=0.2.10" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "bcrypt", specifier = ">=4.2.1" }, - { name = "casbin", specifier = ">=1.38.0" }, - { name = "casbin-async-sqlalchemy-adapter", specifier = ">=1.7.0" }, { name = "celery", specifier = "==5.3.6" }, { name = "celery-aio-pool", specifier = "==0.1.0rc8" }, { name = "cryptography", specifier = ">=44.0.0" }, @@ -1845,15 +1816,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 }, ] -[[package]] -name = "simpleeval" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/6f/15be211749430f52f2c8f0c69158a6fc961c03aac93fa28d44d1a6f5ebc7/simpleeval-1.0.3.tar.gz", hash = "sha256:67bbf246040ac3b57c29cf048657b9cf31d4e7b9d6659684daa08ca8f1e45829", size = 24358 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e9/e58082fbb8cecbb6fb4133033c40cc50c248b1a331582be3a0f39138d65b/simpleeval-1.0.3-py3-none-any.whl", hash = "sha256:e3bdbb8c82c26297c9a153902d0fd1858a6c3774bf53ff4f134788c3f2035c38", size = 15762 }, -] - [[package]] name = "six" version = "1.17.0"