feat(app): 应用级访问控制——AppAccessPermission 权限工厂挂载邮箱/Gitea/商店/储值/钱包/企业 API(未安装租户拒绝,管理员豁免)

This commit is contained in:
TermiNexus committed 2026-08-13 05:34:42 +08:00
1 parent 1066d82c75
commit 904e225b90
4 files changed
+79 -21

No files matched your search

+14 -7
View File
@@ -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'
+43
View File
@@ -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
+9 -5
View File
@@ -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()
+13 -9
View File
@@ -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