diff --git a/gvsds/apps/crb/views.py b/gvsds/apps/crb/views.py index 2ee42eab..a80257c2 100644 --- a/gvsds/apps/crb/views.py +++ b/gvsds/apps/crb/views.py @@ -28,6 +28,13 @@ from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from gvsdsdk.model_utils import UUIDObj +from gvsds.apps.gerp.app_access import AppAccessPermission + +# 各业务应用编码(未安装的企业租户无法使用对应业务功能) +_APP_ACCESS_WALLET = AppAccessPermission('wallet') +_APP_ACCESS_STORE_VALUE = AppAccessPermission('store-value') +_APP_ACCESS_SHOP = AppAccessPermission('shop') +_APP_ACCESS_ENTERPRISE = AppAccessPermission('enterprise') from .business import ( StoreValueRule, @@ -334,7 +341,7 @@ class CmeBalancesViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): """ queryset = CmeBalances.objects.all() serializer_class = CmeBalancesSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_WALLET] company_private_page = 'pages/private/CreateE/wallet.html' @action(detail=False, methods=['get']) @@ -668,7 +675,7 @@ class EnterpriseViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): """ queryset = Enterprise.objects.all() serializer_class = EnterpriseSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_ENTERPRISE] company_private_page = 'pages/private/CreateE/enterprise.html' company_private_exempt = {'get_enterprise_card'} @@ -1084,7 +1091,7 @@ class EnterpriseUserViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): """ queryset = EnterpriseUser.objects.all() serializer_class = EnterpriseUserSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_ENTERPRISE] company_private_page = 'pages/private/CreateE/enterprise.html' company_private_exempt = {'get_members'} @@ -1282,7 +1289,7 @@ class CmeStoreValueViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): """ queryset = CmeStoreValue.objects.all() serializer_class = CmeStoreValueSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_STORE_VALUE] company_private_page = 'pages/private/CreateE/StoreValue.html' company_private_exempt = {'get_store_value_rules'} @@ -1544,7 +1551,7 @@ class ShopProductViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): queryset = ShopProduct.objects.all() serializer_class = ShopProductSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_SHOP] company_private_page = 'pages/private/CreateE/shop.html' company_private_company_uuid = 'ef67bb82-a590-402e-a893-81ae6127fb08' company_private_exempt = {'list_public', 'detail'} @@ -1813,7 +1820,7 @@ class ShopCartViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): queryset = ShopCartItem.objects.all() serializer_class = ShopCartItemSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_SHOP] company_private_page = 'pages/private/CreateE/shop.html' company_private_company_uuid = 'ef67bb82-a590-402e-a893-81ae6127fb08' @@ -2000,7 +2007,7 @@ class ShopOrderViewSet(CompanyPrivateAccessMixin, viewsets.ModelViewSet): queryset = ShopOrder.objects.all() serializer_class = ShopOrderSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _APP_ACCESS_SHOP] company_private_page = 'pages/private/CreateE/shop.html' company_private_company_uuid = 'ef67bb82-a590-402e-a893-81ae6127fb08' diff --git a/gvsds/apps/gerp/app_access.py b/gvsds/apps/gerp/app_access.py new file mode 100644 index 00000000..4b54100f --- /dev/null +++ b/gvsds/apps/gerp/app_access.py @@ -0,0 +1,43 @@ +"""模块:应用级访问控制(AppAccess) + +将功能/应用体系的 CheckAccess 硬校验挂到各应用 API 入口: +- 管理员(admin:super / admin:tenant):豁免——与菜单过滤逻辑一致,管理员全览 +- 普通用户:按 AppManager.CheckAccess 校验(企业功能按租户安装状态、个人应用按个人启用状态) +- 未安装/未启用:返回 403(DRF permission 拒绝) + +用法(视图 permission_classes): + permission_classes = [IsAuthenticated, AppAccessPermission('mailbox')] + +注意:同一视图类中的 AllowAny 公开 action(如商品列表 list_public)不受影响—— +DRF 的 action 级 permission_classes 会覆盖 viewset 级。 +""" + +from rest_framework import permissions + + +def AppAccessPermission(AppCode): + """生成应用级访问控制权限类(工厂) + + Args: + AppCode: 应用编码(如 mailbox / gitea / shop / store-value / wallet / enterprise) + """ + class _AppAccessPermission(permissions.BasePermission): + message = '该功能未开通,请联系企业管理员' + + def has_permission(self, request, view): + user = getattr(request, 'user', None) + if not (user and getattr(user, 'IsAuthenticated', False)): + return False + # 管理员豁免(与菜单过滤一致:admin:super/admin:tenant 全览) + from gvsdsdk.auth.admin_service import IsAdmin + if IsAdmin(request=request, user=user): + return True + # 解析当前租户/用户上下文(CurrentRoleMiddleware 已设置 request.current_tenant_uuid) + tenant_uuid = getattr(request, 'current_tenant_uuid', None) + user_uuid = getattr(user, 'UserUUID', None) + if not tenant_uuid or not user_uuid: + return False + from .app_manager import AppManager + return AppManager.CheckAccess(tenant_uuid, user_uuid, AppCode) is not None + + return _AppAccessPermission diff --git a/gvsds/apps/gerp/views/gitea.py b/gvsds/apps/gerp/views/gitea.py index ed3d60b9..63df37a1 100644 --- a/gvsds/apps/gerp/views/gitea.py +++ b/gvsds/apps/gerp/views/gitea.py @@ -22,6 +22,10 @@ from rest_framework.response import Response from gvsds import secrets as app_secrets from gvsdsdk.model_utils import UUIDObj from gvsdsdk.auth.permissions import IsAdminUser +from ..app_access import AppAccessPermission + +# Gitea 功能应用编码(未安装的企业租户员工无法使用代码托管) +_GITEA_ACCESS = AppAccessPermission('gitea') from . import ELTAuthenticatedAPIView from ..models import UserGiteaAccount, UserMailbox @@ -105,7 +109,7 @@ def _IsOwnerRole(user_bytes): class GiteaSessionView(ELTAuthenticatedAPIView): """返回 Gitea 连接配置(BaseUrl / Org / OwnerRole)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _GITEA_ACCESS] def get(self, request): return Response({'code': 0, 'data': GiteaApiClient.SessionConfig()}) @@ -114,7 +118,7 @@ class GiteaSessionView(ELTAuthenticatedAPIView): class GiteaApplyView(ELTAuthenticatedAPIView): """员工自助申请 Gitea 账号(自选用户名 + 自设密码)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _GITEA_ACCESS] def post(self, request): user_bytes = _GetUserBytes(request) @@ -205,7 +209,7 @@ class GiteaApplyView(ELTAuthenticatedAPIView): class GiteaMyAccountView(ELTAuthenticatedAPIView): """我的 Gitea 账号信息 + 连接配置。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _GITEA_ACCESS] def get(self, request): acct = UserGiteaAccount.objects.filter(UserUUID=request.user).first() @@ -231,7 +235,7 @@ class GiteaMyAccountView(ELTAuthenticatedAPIView): class GiteaChangePasswordView(ELTAuthenticatedAPIView): """修改 Gitea 账号密码(管理员 API,无需旧密码)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _GITEA_ACCESS] def post(self, request): acct = UserGiteaAccount.objects.filter(UserUUID=request.user).first() @@ -255,7 +259,7 @@ class GiteaAvatarView(ELTAuthenticatedAPIView): 故用用户的 Gitea 密码做 Basic Auth;密码仅此一次传输,不落库。 """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _GITEA_ACCESS] def post(self, request): acct = UserGiteaAccount.objects.filter(UserUUID=request.user).first() diff --git a/gvsds/apps/gerp/views/mail.py b/gvsds/apps/gerp/views/mail.py index f7cc621a..2a81f380 100644 --- a/gvsds/apps/gerp/views/mail.py +++ b/gvsds/apps/gerp/views/mail.py @@ -27,6 +27,10 @@ from rest_framework.response import Response from gvsds import secrets as app_secrets from gvsdsdk.model_utils import UUIDObj from gvsdsdk.auth.permissions import IsAdminUser +from ..app_access import AppAccessPermission + +# 邮箱功能应用编码(未安装的企业租户员工无法使用邮箱) +_MAIL_ACCESS = AppAccessPermission('mailbox') from . import ELTAuthenticatedAPIView from ..models import UserMailbox @@ -204,7 +208,7 @@ def _CheckCompanyAllowed(user_bytes): class MailSessionView(ELTAuthenticatedAPIView): """返回浏览器直连 JMAP 所需的连接配置。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def get(self, request): return Response({'code': 0, 'data': StalwartMailManager.SessionConfig()}) @@ -213,7 +217,7 @@ class MailSessionView(ELTAuthenticatedAPIView): class MailApplyView(ELTAuthenticatedAPIView): """员工自助申请邮箱(xxx@gvsds.com)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def post(self, request): user_bytes = _GetUserBytes(request) @@ -290,9 +294,9 @@ class MailApplyView(ELTAuthenticatedAPIView): class MailChangePasswordView(ELTAuthenticatedAPIView): - """修改自己的邮箱密码。""" + """修改邮箱密码(校验旧密码)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def post(self, request): mailbox_name = (request.data.get('MailboxName') or '').strip().lower() @@ -323,7 +327,7 @@ class MailChangePasswordView(ELTAuthenticatedAPIView): class MailMyMailboxView(ELTAuthenticatedAPIView): """我的邮箱列表 + JMAP 连接配置(供浏览器直连)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def get(self, request): session = StalwartMailManager.SessionConfig() @@ -409,7 +413,7 @@ class MailLoginView(ELTAuthenticatedAPIView): 响应:{code: 0, data: {Token, MailboxName}} """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def post(self, request): mailbox_name = (request.data.get('MailboxName') or '').strip().lower() @@ -434,7 +438,7 @@ class MailLoginView(ELTAuthenticatedAPIView): class MailLogoutView(ELTAuthenticatedAPIView): """邮箱登出——吊销会话 token(删除内存中的邮箱凭据)。""" - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def post(self, request): _RevokeMailToken(request.data.get('Token') or '') @@ -453,7 +457,7 @@ class MailJmapView(ELTAuthenticatedAPIView): 响应:{code: 0, data: {AccountId, Data}},Data 为 Stalwart 原始 methodResponses。 """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def post(self, request): entry = _GetMailToken(request.data.get('Token') or '') @@ -524,7 +528,7 @@ class MailContactsView(ELTAuthenticatedAPIView): POST /API/Mail/Contacts - 新增/更新联系人(ContactEmail 必填) """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, _MAIL_ACCESS] def get(self, request): from django.db.models import Q