From 24db976efd0b6386fac898291db0dbd03ae519c5 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Mon, 28 Sep 2026 18:23:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(gerp):=20=E6=8E=88=E6=9D=83=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E9=99=90=E5=AE=9A=E5=85=AC=E5=8F=B8/=E7=A7=9F?= =?UTF-8?q?=E6=88=B7/=E5=B9=B3=E5=8F=B0=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8C=89=E5=85=B6=E7=BA=A7=E5=88=AB=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E6=95=B0=E6=8D=AE=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 菜单 app-pending 加权限属性 admin:company(迁移 0047):公司管理员 + 租户管理员 + 平台管理员可见 - AppPendingView/AppApproveView 权限放宽到 IsCompanyAdminUser,按管理员级别解析可管辖公司 - ApproveUserApp 增加越权校验;返回值统一为三元组(失败原为 2 元组,视图解包会抛 ValueError) - CheckAdmin 下发 is_company_admin 供前端判定 --- gvsds/apps/gerp/app_manager.py | 36 +++++++++--- .../gerp/migrations/0047_app_pending_scope.py | 53 ++++++++++++++++++ gvsds/apps/gerp/views/app.py | 56 ++++++++++++++----- gvsds/apps/gerp/views/form_api.py | 11 ++++ 4 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 gvsds/apps/gerp/migrations/0047_app_pending_scope.py diff --git a/gvsds/apps/gerp/app_manager.py b/gvsds/apps/gerp/app_manager.py index c8df83ac..a327aa61 100644 --- a/gvsds/apps/gerp/app_manager.py +++ b/gvsds/apps/gerp/app_manager.py @@ -17,7 +17,7 @@ import datetime import uuid -from gvsdsdk.auth.managers import AppManager as SdkAppManager, CompanyManager, PositionManager +from gvsdsdk.auth.managers import AppManager as SdkAppManager, CompanyManager, PositionManager, RoleManager from gvsdsdk.model_utils import UUIDObj # 投放形态 @@ -1027,17 +1027,30 @@ class AppManager: return True, '' @staticmethod - def ApproveUserApp(OperatorUUID, UserAppUUID, Approved=True): - """租户管理员审批个人授权申请。 + def ApproveUserApp(OperatorUUID, UserAppUUID, Approved=True, AllowedCompanyUUIDs=None): + """公司/租户/平台管理员审批个人授权申请。 + + AllowedCompanyUUIDs: + - None:不限制(平台管理员 admin:super) + - 集合:申请人的公司必须命中该集合,否则拒绝(越权保护) Returns: - (ok, err, flat) + (ok, err, flat):三种返回值固定为三元组(失败时 flat 为 None), + 避免调用方按三元组解包时在失败分支抛 ValueError。 """ ua = SdkAppManager.GetUserAppByUUID(UserAppUUID) if ua is None: - return False, '申请记录不存在' + return False, '申请记录不存在', None if ua.ApplyStatus != APPLY_PENDING: - return False, '该申请已处理(非待审批状态)' + return False, '该申请已处理(非待审批状态)', None + if AllowedCompanyUUIDs is not None: + allowed = [c for c in AllowedCompanyUUIDs if c] + applicant_companies = { + _ToBytes(ur.CompanyUUID) + for ur in RoleManager.GetUserRoleDetails(_ToBytes(ua.UserUUID)) + } + if not allowed or not (applicant_companies & {_ToBytes(c) for c in allowed}): + return False, '无权审批:申请人不在您的管理范围内', None apply_status = APPLY_APPROVED if Approved else APPLY_DENIED use_status = 1 if Approved else 0 approve_by = _ToBytes(OperatorUUID) @@ -1054,10 +1067,15 @@ class AppManager: return True, '', _AppFlat(app, user_record=ua) @staticmethod - def ListPendingApplications(TenantUUID): - """租户内待审批的个人授权申请(供租户管理员审批台)""" + def ListPendingApplications(AllowedCompanyUUIDs=None): + """待审批的个人授权申请(供审批台) + + AllowedCompanyUUIDs: + - None:不限制(平台管理员 admin:super) + - 集合:仅申请人的公司命中该集合的申请(公司/租户管理员,由视图计算) + """ rows = [] - for ua in SdkAppManager.ListPendingApplications(TenantUUID): + for ua in SdkAppManager.ListPendingApplications(AllowedCompanyUUIDs=AllowedCompanyUUIDs): app = AppManager.GetApp(ua.AppUUID) if app is None: continue diff --git a/gvsds/apps/gerp/migrations/0047_app_pending_scope.py b/gvsds/apps/gerp/migrations/0047_app_pending_scope.py new file mode 100644 index 00000000..47f5c696 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0047_app_pending_scope.py @@ -0,0 +1,53 @@ +"""Data Migration: 授权审批菜单限定为「公司管理员及以上」可见 + +背景: + ``Menu`` 表中 ``app-pending``(授权审批,pages/app/pending.html)的 + ``PermissionAttr`` 为空 → 菜单对所有角色可见,普通成员也能看到审批台。 + 改为 ``admin:company`` 后:持有该权限码的角色(公司管理员)+ admin:tenant + (租户管理员)+ admin:super(平台管理员)可见,其余角色不可见。 + 过滤逻辑见 gvsds/apps/gerp/views/menu.py 的菜单权限过滤段 + (perm_attrs 含 admin:super 时按 IsSuperAdmin 判定;否则非管理员走 + PermissionVerifier 按权限码判定,而 PermissionVerifier 对两种管理员直接放行)。 + +实现说明: + Menu 走 historical model(字段名为小写形式,db_column 才是 PascalCase)。 + 幂等:重复执行只是重复写入相同值。 + +注:``Permission`` 表中 ``admin:company`` 虽有两行,但分属两个不同租户 + (TenantUUID 不同,各 0 条 RolePermission 引用),是各租户各自的权限定义, + 并非误插重复数据,故**不清理**——删除会使其中一个租户在权限管理中失去该项。 +""" +from django.db import migrations + + +APPRINT_MENU_NAME = 'app-pending' +SCOPE_PERM_CODE = 'admin:company' + + +def ApplyAppPendingScope(apps, schema_editor): + """正向:菜单加权限属性(幂等)""" + Menu = apps.get_model('gerp', 'Menu') + Menu.objects.filter(menu_name=APPRINT_MENU_NAME).update( + permission_attr=SCOPE_PERM_CODE, + permission_level=1.0, + ) + + +def RevertAppPendingScope(apps, schema_editor): + """逆向:还原为空权限属性""" + Menu = apps.get_model('gerp', 'Menu') + Menu.objects.filter(menu_name=APPRINT_MENU_NAME).update( + permission_attr='', + permission_level=1.0, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0046_talk_models_state'), + ] + + operations = [ + migrations.RunPython(ApplyAppPendingScope, reverse_code=RevertAppPendingScope), + ] diff --git a/gvsds/apps/gerp/views/app.py b/gvsds/apps/gerp/views/app.py index 2e8bf43f..4403d18e 100644 --- a/gvsds/apps/gerp/views/app.py +++ b/gvsds/apps/gerp/views/app.py @@ -12,17 +12,17 @@ URL 路径最后一段作为 action 名称: POST /API/App/Enable - 个人:启用应用(AuthRequired=1 需先获授权) POST /API/App/Disable - 个人:停用应用 POST /API/App/Apply - 个人:申请企业授权 - POST /API/App/Approve - 租户管理员:审批个人授权申请 - GET /API/App/Pending - 租户管理员:待审批申请列表 + POST /API/App/Approve - 公司/租户/平台管理员:审批个人授权申请 + GET /API/App/Pending - 公司/租户/平台管理员:待审批申请列表 """ import logging from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from gvsdsdk.auth.admin_service import IsAdmin +from gvsdsdk.auth.admin_service import IsAdmin, IsSuperAdmin, IsTenantAdmin from gvsdsdk.auth.managers import CompanyManager, RoleManager -from gvsdsdk.auth.permissions import IsAdminUser +from gvsdsdk.auth.permissions import IsAdminUser, IsCompanyAdminUser from gvsdsdk.model_utils import UUIDObj from . import ELTAuthenticatedAPIView @@ -75,6 +75,25 @@ def _GetCompanyBytes(request): return None +def _ResolveApproveScopeCompanies(request): + """解析授权审批的可管辖公司范围(返回公司 UUID 列表;None = 不限制) + + 与菜单「授权审批」的可见范围一致(admin:company / admin:tenant / admin:super): + - 平台管理员(admin:super):None,可见全部租户 + - 租户管理员(admin:tenant):本租户下全部公司 + - 公司管理员(admin:company):仅当前公司 + """ + if IsSuperAdmin(request=request): + return None + if IsTenantAdmin(request=request): + tenant_bytes = _GetTenantBytes(request) + if not tenant_bytes: + return [] + return CompanyManager.GetCompanyUUIDs(tenant_bytes) + company_bytes = _GetCompanyBytes(request) + return [company_bytes] if company_bytes else [] + + def _ResolveRoleUUID(request): """当前角色 UUID bytes(Cookie CurrentRoleUUID)""" role_uuid_str = request.COOKIES.get('CurrentRoleUUID', '') @@ -440,13 +459,16 @@ class AppApplyView(ELTAuthenticatedAPIView): ok, err = AppManager.ApplyUserApp(user_bytes, app_uuid) if not ok: return Response({'code': 1, 'msg': err}, status=400) - return Response({'code': 0, 'msg': '申请已提交,等待租户管理员审批'}) + return Response({'code': 0, 'msg': '申请已提交,等待管理员审批'}) class AppApproveView(ELTAuthenticatedAPIView): - """租户管理员:审批个人授权申请。""" + """公司/租户/平台管理员:审批个人授权申请。 - permission_classes = [IsAuthenticated, IsAdminUser] + 仅能审批自己管理范围内(本公司 / 本租户 / 全部)申请人的申请。 + """ + + permission_classes = [IsAuthenticated, IsCompanyAdminUser] def post(self, request): user_app_uuid = (request.data.get('UserAppUUID') or '').strip() @@ -456,22 +478,28 @@ class AppApproveView(ELTAuthenticatedAPIView): if isinstance(approved, str): approved = approved.lower() in ('1', 'true', 'yes') user_bytes = _GetUserBytes(request) - ok, err, flat = AppManager.ApproveUserApp(user_bytes, user_app_uuid, Approved=bool(approved)) + ok, err, flat = AppManager.ApproveUserApp( + user_bytes, user_app_uuid, + Approved=bool(approved), + AllowedCompanyUUIDs=_ResolveApproveScopeCompanies(request), + ) if not ok: return Response({'code': 1, 'msg': err}, status=400) return Response({'code': 0, 'msg': '已通过' if approved else '已拒绝', 'data': flat}) class AppPendingView(ELTAuthenticatedAPIView): - """租户管理员:待审批的个人授权申请列表。""" + """公司/租户/平台管理员:待审批的个人授权申请列表。 - permission_classes = [IsAuthenticated, IsAdminUser] + 数据范围:平台管理员=全部;租户管理员=本租户;公司管理员=本公司。 + """ + + permission_classes = [IsAuthenticated, IsCompanyAdminUser] def get(self, request): - tenant_bytes = _GetTenantBytes(request) - if not tenant_bytes: - return Response({'code': 0, 'data': {'list': [], 'total': 0}}) - rows = AppManager.ListPendingApplications(tenant_bytes) + rows = AppManager.ListPendingApplications( + AllowedCompanyUUIDs=_ResolveApproveScopeCompanies(request), + ) return Response({'code': 0, 'data': {'list': rows, 'total': len(rows)}}) diff --git a/gvsds/apps/gerp/views/form_api.py b/gvsds/apps/gerp/views/form_api.py index 66a20b26..2799d555 100644 --- a/gvsds/apps/gerp/views/form_api.py +++ b/gvsds/apps/gerp/views/form_api.py @@ -52,6 +52,15 @@ def _is_tenant_admin(request): return IsTenantAdmin(request) +def _is_company_admin(request): + """判断用户是否为公司管理员及以上(admin:company / admin:tenant / admin:super) + + 委托给 IsCompanyAdmin:租户管理员与跨租户管理员自动继承公司管理员能力。 + """ + from gvsdsdk.auth.admin_service import IsCompanyAdmin + return IsCompanyAdmin(request) + + def _make_service(request, user): """构造 FormService,自动传入当前公司 UUID、角色 UUID 和租户 UUID。 上下文来自 CurrentRoleMiddleware 注入的 request 属性。 @@ -407,6 +416,7 @@ class CheckAdminView(ELTAuthenticatedAPIView): is_admin = _is_admin(request) # admin:super 或 admin:tenant is_super_admin = _is_super_admin(request) # 仅 admin:super(跨租户,最高角色级) is_tenant_admin = _is_tenant_admin(request) # 仅 admin:tenant(租户内,中角色级) + is_company_admin = _is_company_admin(request) # admin:company / admin:tenant / admin:super current_role_uuid, current_company_uuid, current_tenant_uuid = _get_current_context(request) # 获取角色名 role_name = '' @@ -450,6 +460,7 @@ class CheckAdminView(ELTAuthenticatedAPIView): 'is_superuser': is_superuser, 'is_super_admin': is_super_admin, 'is_tenant_admin': is_tenant_admin, + 'is_company_admin': is_company_admin, 'current_role_name': role_name, 'current_role_uuid': UUIDObj(current_role_uuid).str if current_role_uuid else None, 'current_company_uuid': UUIDObj(current_company_uuid).str if current_company_uuid else None,