feat(gerp): 授权审批限定公司/租户/平台管理员,并按其级别收窄数据范围

- 菜单 app-pending 加权限属性 admin:company(迁移 0047):公司管理员 + 租户管理员 + 平台管理员可见

- AppPendingView/AppApproveView 权限放宽到 IsCompanyAdminUser,按管理员级别解析可管辖公司

- ApproveUserApp 增加越权校验;返回值统一为三元组(失败原为 2 元组,视图解包会抛 ValueError)

- CheckAdmin 下发 is_company_admin 供前端判定
This commit is contained in:
TermiNexus committed 2026-09-28 18:23:13 +08:00
1 parent 5236821c97
commit 24db976efd
4 files changed
+133 -23

No files matched your search

+27 -9
View File
@@ -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
@@ -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),
]
+42 -14
View File
@@ -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)}})
+11
View File
@@ -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,