diff --git a/TempLate/1 b/TempLate/1 index 46d3028c..88f55ddb 100644 --- a/TempLate/1 +++ b/TempLate/1 @@ -1,5 +1,291 @@ +
+
+ +
+
+ + +
+
+ + Studio Report + + + 2025-10-25 + + + 已归档 + +
+

+ 银河锋锐软件开发工作室 + 第六次全体会议 + 纪要 +

+

+ 本次会议围绕 15 项关键议题 展开深度讨论,全面梳理工作室现状、部门制改革方向与长期发展战略,由工作室负责人 TermiNexus 主持并发表重要讲话。 +

+
+ + +
+ + +
+ +
+
+
+
+ +
+

媒体文件不可用

+

录像文件已损坏或丢失,无法播放

+
+
+
+
+
+

会议全程录像

+

原始录制:2025-10-25 · 16:07

+
+ + 文件丢失 + +
+
+ + +
+
+ TermiNexus +
+
+

主持 & 主讲

+

TermiNexus

+

工作室负责人 · Studio Director

+
+
+

会议编号

+

#GT2025(GX-006)

+
+
+ + +
+
+ + 会议关键指标 +
+
+
+

15

+

议题数量

+
+
+

4

+

核心项目

+
+
+

6

+

部门覆盖

+
+
+
+ + + +
+ + +
+ + +
+
+ 核心议题摘要 +
+
+ + + +
+
+
+
+
+

成员发展与关怀

+ 01 +
+

重点关注九年级成员发展,强调平衡学业、生活与工作室事务;关注成员身心健康,提醒做好冬季健康防护,体现以人为本的管理理念。

+
+ 人员管理 + 成员关怀 +
+
+
+
+ + +
+
+
+
+
+

组织架构改革 · 金字塔制 → 部门制

+ 02 +
+

推进从金字塔式组委制到扁平化部门制的核心变革,明确各部门分工与职能边界。识别当前痛点:人手短缺、新部门建设滞后、执行效率不足,提出重启人才计划作为核心解决方案。

+
+ 组织变革 + 架构优化 + 人才计划 +
+
+
+
+ + +
+
+
+
+
+

部门重建与核心职能界定

+ 03 +
+

系统梳理现有部门构成及缺岗情况,表彰工程部、美工部核心成员的突出贡献。明确各部门核心职能定位与跨部门协作规范,为后续规模化扩张奠定制度基础。

+
+ 部门建设 + 协作规范 +
+
+
+
+ + +
+
+
+
+
+

品牌建设与长期战略规划

+ 04 +
+

探讨工作室名称定位、商标体系与企业文化内核建设;明确项目推进分工、质量保障标准及测试机制,系统优化 GERP 任务追踪、工作复盘与成员考核机制,提出新项目精细化推进与弹性时间管理方案。

+
+ 品牌战略 + 质量管理 + GERP 优化 +
+
+
+
+ + +
+
+ +
+
+ +
+
+ + +
+ + +
+
+
+ +
+

+ 部门制核心是分工明确,更是心往一处想 —— 工程部铸骨,美工部赋魂。 +

+

团队的价值不止于项目成果,更在于让每个成员在成长中遇见更好的自己。

+
+
+ +
+
+

TermiNexus

+

六会金句 · 2025-10-25

+
+
+
+
+ + +
+
+
+ +
+

+ 和而不同、创新为主、以人为本 +

+

+ Wishing you eternal happiness and joy, and thank you for bringing vitality to the organization. +

+
+
+ +
+
+

银河锋锐软件开发工作室

+

组织寄语 · 2025-10-25

+
+
+
+
+ +
+ + +
+
+

本次会议已形成正式文件

+

包含完整决议事项、部门任务分配及后续跟进计划

+
+ +
+ +
+
+ + + + diff --git a/TempLate/GERP/pages/Company/company-detail.html b/TempLate/GERP/pages/Company/company-detail.html index 4706b47c..f034388d 100644 --- a/TempLate/GERP/pages/Company/company-detail.html +++ b/TempLate/GERP/pages/Company/company-detail.html @@ -152,8 +152,8 @@ hoverable: true, bordered: true, api: { - get: buildApiUrl('/API/FormAPI/Get/Department'), - add: buildApiUrl('/API/FormAPI/Add/Department'), + get: buildApiUrl('/API/FormAPI/Get/Department', { CompanyUUID: companyUUID }), + add: buildApiUrl('/API/FormAPI/Add/Department', { CompanyUUID: companyUUID }), edit: buildApiUrl('/API/FormAPI/Modify/Department/'), modify: buildApiUrl('/API/FormAPI/ModifyItem/Department/'), delete: buildApiUrl('/API/FormAPI/Delete/Department/'), @@ -188,8 +188,8 @@ hoverable: true, bordered: true, api: { - get: buildApiUrl('/API/FormAPI/Get/Workgroup'), - add: buildApiUrl('/API/FormAPI/Add/Workgroup'), + get: buildApiUrl('/API/FormAPI/Get/Workgroup', { CompanyUUID: companyUUID }), + add: buildApiUrl('/API/FormAPI/Add/Workgroup', { CompanyUUID: companyUUID }), edit: buildApiUrl('/API/FormAPI/Modify/Workgroup/'), modify: buildApiUrl('/API/FormAPI/ModifyItem/Workgroup/'), delete: buildApiUrl('/API/FormAPI/Delete/Workgroup/'), @@ -229,8 +229,8 @@ hoverable: true, bordered: true, api: { - get: buildApiUrl('/API/FormAPI/Get/UserRole'), - add: buildApiUrl('/API/FormAPI/Add/UserRole'), + get: buildApiUrl('/API/FormAPI/Get/UserRole', { CompanyUUID: companyUUID }), + add: buildApiUrl('/API/FormAPI/Add/UserRole', { CompanyUUID: companyUUID }), edit: buildApiUrl('/API/FormAPI/Modify/UserRole/'), modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/'), delete: buildApiUrl('/API/FormAPI/Delete/UserRole/'), diff --git a/TempLate/GERP/pages/Company/company.html b/TempLate/GERP/pages/Company/company.html index 34b1f222..992cc1ee 100644 --- a/TempLate/GERP/pages/Company/company.html +++ b/TempLate/GERP/pages/Company/company.html @@ -24,7 +24,7 @@ { field: 'CompanyName', title: '公司全称', width: 200, type: 'text' }, { field: 'CompanyShort', title: '公司简称', width: 120, type: 'text' }, { field: 'CompanyStatus', title: '状态', width: 70, type: 'select', options: [{value:0,label:'禁用'},{value:1,label:'启用'}] }, - { field: 'ParentCompanyUUID', title: '上级公司', width: 140, type: 'text' }, + CompanyUtils.epColumn('ParentCompanyUUID', '上级公司', 'Company', { editable: false }), { field: 'CreateTime', title: '创建时间', width: 160, type: 'datetime', editable: false }, ], onView: (row) => { diff --git a/TempLate/GERP/pages/Company/department.html b/TempLate/GERP/pages/Company/department.html index b05fcc61..3b5093f6 100644 --- a/TempLate/GERP/pages/Company/department.html +++ b/TempLate/GERP/pages/Company/department.html @@ -22,8 +22,8 @@ { field: 'DeptName', title: '部门名称', width: 160, type: 'text' }, { field: 'DeptSort', title: '排序', width: 80, type: 'number' }, { field: 'DeptStatus', title: '状态', width: 80, type: 'select', options: [{value:0,label:'禁用'},{value:1,label:'启用'}] }, - { field: 'CompanyUUID', title: '所属公司', width: 160, type: 'text', editable: false, hideInForm: true }, - { field: 'ParentDeptUUID', title: '上级部门', width: 160, type: 'text' }, + CompanyUtils.epColumn('CompanyUUID', '所属公司', 'Company', { editable: false, hideInForm: true }), + CompanyUtils.epColumn('ParentDeptUUID', '上级部门', 'Department'), { field: 'CreateTime', title: '创建时间', width: 160, type: 'datetime', editable: false }, ], onView: (row) => { diff --git a/TempLate/GERP/pages/Company/workgroup-detail.html b/TempLate/GERP/pages/Company/workgroup-detail.html index 0e4160d3..7e6f401e 100644 --- a/TempLate/GERP/pages/Company/workgroup-detail.html +++ b/TempLate/GERP/pages/Company/workgroup-detail.html @@ -66,18 +66,24 @@ currentData = data; CompanyUtils.setPageTitle(data.WorkgroupName || '工作组详情'); + // 解析 UUID 字段为名称 + const uuidMap = {}; + if (data.CompanyUUID) uuidMap.company = { table: 'Company', uuids: [data.CompanyUUID] }; + const resolved = await CompanyUtils.resolveUUIDs(uuidMap); + // 渲染基本信息 + 表格容器 containerEl.innerHTML = `

基本信息

工作组名称
${CompanyUtils.esc(data.WorkgroupName) || '-'}
-
工作组状态
${CompanyUtils.statusTag(data.WorkgroupStatus)}
-
创建时间
${CompanyUtils.formatTime(data.WorkgroupCreateTime)}
+
英文名称
${CompanyUtils.esc(data.WorkgroupEnglishName) || '-'}
+
所属公司
${CompanyUtils.esc(resolved.company?.[data.CompanyUUID] || data.CompanyUUID) || '-'}
+
工作组介绍
${CompanyUtils.esc(data.WorkgroupIntroduction) || '-'}
-

工作组成员

+

工作组角色

`; @@ -92,10 +98,9 @@ memberTable = new TableEngine({ container: '#member-table-container', height: '400px', - tableName: 'UserRole', + tableName: 'WorkgroupRole', columns: [ - { field: 'UserRoleUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true }, - CompanyUtils.epColumn('UserUUID', '用户名', 'User', { editable: false }), + { field: 'WorkgroupRoleUUID', title: '关联UUID', primaryKey: true, editable: false, hideInForm: true }, CompanyUtils.epColumn('RoleUUID', '角色名', 'Role'), { field: 'RoleType', title: '角色类型', type: 'select', options: [{ value: 0, label: '组员' }, { value: 1, label: '组长' }] }, { field: 'CreateTime', title: '创建时间', type: 'datetime', editable: false }, @@ -112,16 +117,16 @@ hoverable: true, bordered: true, api: { - get: buildApiUrl('/API/FormAPI/Get/UserRole', { WorkgroupUUID: workgroupUUID }), - add: buildApiUrl('/API/FormAPI/Add/UserRole', { WorkgroupUUID: workgroupUUID }), - edit: buildApiUrl('/API/FormAPI/Modify/UserRole/'), - modify: buildApiUrl('/API/FormAPI/ModifyItem/UserRole/'), - delete: buildApiUrl('/API/FormAPI/Delete/UserRole/'), + get: buildApiUrl('/API/FormAPI/Get/WorkgroupRole', { WorkgroupUUID: workgroupUUID }), + add: buildApiUrl('/API/FormAPI/Add/WorkgroupRole', { WorkgroupUUID: workgroupUUID }), + edit: buildApiUrl('/API/FormAPI/Modify/WorkgroupRole/'), + modify: buildApiUrl('/API/FormAPI/ModifyItem/WorkgroupRole/'), + delete: buildApiUrl('/API/FormAPI/Delete/WorkgroupRole/'), }, onView: (row) => { - let url = `/pages/Company/employee-detail.html?UserUUID=${encodeURIComponent(row.UserUUID || '')}`; + let url = `/pages/Company/role-detail.html?RoleUUID=${encodeURIComponent(row.RoleUUID || '')}`; if (currentServer !== 'gvsds-master') url += `&server=${encodeURIComponent(currentServer)}`; - GERP.Navigate(url, row.UserUUID_label || '员工详情'); + GERP.Navigate(url, row.RoleUUID_label || '角色详情'); }, }); } diff --git a/TempLate/GERP/pages/Company/workgroup.html b/TempLate/GERP/pages/Company/workgroup.html index 3669aa27..253204ce 100644 --- a/TempLate/GERP/pages/Company/workgroup.html +++ b/TempLate/GERP/pages/Company/workgroup.html @@ -21,6 +21,7 @@ { field: 'WorkgroupName', title: '工作组名称', width: 160, type: 'text' }, { field: 'WorkgroupEnglishName', title: '英文名称', width: 160, type: 'text' }, { field: 'WorkgroupIntroduction', title: '工作组介绍', width: 240, type: 'textarea' }, + CompanyUtils.epColumn('CompanyUUID', '所属公司', 'Company'), ], onView: (row) => { console.log('[workgroup] onView row:', row); diff --git a/TempLate/GERP/pages/system/userrole-debug.html b/TempLate/GERP/pages/system/userrole-debug.html index 0c944f20..865b662e 100644 --- a/TempLate/GERP/pages/system/userrole-debug.html +++ b/TempLate/GERP/pages/system/userrole-debug.html @@ -126,7 +126,8 @@ document.getElementById('ctx-role').textContent = d.current_role_name || d.current_role_uuid || '-'; document.getElementById('ctx-company').textContent = d.current_company_uuid || '-'; document.getElementById('ctx-tenant').textContent = d.current_tenant_uuid || '-'; - document.getElementById('ctx-scope').textContent = d.company_scope === false ? '已关闭(特权模式)' : '已开启(公司隔离)'; + document.getElementById('ctx-company-scope').textContent = d.company_scope === false ? '已关闭(特权模式)' : '已开启(公司隔离)'; + document.getElementById('ctx-tenant-scope').textContent = d.tenant_scope === false ? '已关闭(跨租户管理员)' : '已开启(租户隔离)'; } } catch (e) {} } diff --git a/TempLate/assets/img/Mascot/original/1.png b/TempLate/assets/img/Mascot/original/1.png index be9939a7..e7cf8e70 100644 Binary files a/TempLate/assets/img/Mascot/original/1.png and b/TempLate/assets/img/Mascot/original/1.png differ diff --git a/TempLate/assets/js/main.js b/TempLate/assets/js/main.js index 015eaa03..53b0bb4e 100644 --- a/TempLate/assets/js/main.js +++ b/TempLate/assets/js/main.js @@ -43,12 +43,14 @@ window.addEventListener('load', function () { link.target = '_blank'; link.classList.add('nav-link', 'btn', 'btn-warning', 'zip'); link.style = "padding: 0;"; + const avatarWrap = document.createElement('div'); + avatarWrap.style.cssText = 'width:50px;height:50px;border-radius:50%;overflow:hidden;display:inline-block;'; const img = document.createElement('img'); img.id = 'UserImg'; - img.width = 50; - img.height = 50; + img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;'; img.src = imageUrl; - link.appendChild(img); + avatarWrap.appendChild(img); + link.appendChild(avatarWrap); userDiv.appendChild(link); const userFrame = document.getElementById('UserFrame'); userFrame.innerHTML = ''; @@ -118,30 +120,3 @@ console.log("\n"); - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/TempLate/index.html b/TempLate/index.html index ebe2e2a8..a3d818bc 100644 --- a/TempLate/index.html +++ b/TempLate/index.html @@ -338,311 +338,6 @@ - - - - - - - - - - - - - - - - -
-
- -
-
- - -
-
- - Studio Report - - - 2025-10-25 - - - 已归档 - -
-

- 银河锋锐软件开发工作室 - 第六次全体会议 - 纪要 -

-

- 本次会议围绕 15 项关键议题 展开深度讨论,全面梳理工作室现状、部门制改革方向与长期发展战略,由工作室负责人 TermiNexus 主持并发表重要讲话。 -

-
- - -
- - -
- -
-
-
-
- -
-

媒体文件不可用

-

录像文件已损坏或丢失,无法播放

-
-
-
-
-
-

会议全程录像

-

原始录制:2025-10-25 · 16:07

-
- - 文件丢失 - -
-
- - -
-
- TermiNexus -
-
-

主持 & 主讲

-

TermiNexus

-

工作室负责人 · Studio Director

-
-
-

会议编号

-

#GT2025(GX-006)

-
-
- - -
-
- - 会议关键指标 -
-
-
-

15

-

议题数量

-
-
-

4

-

核心项目

-
-
-

6

-

部门覆盖

-
-
-
- - - -
- - -
- - -
-
- 核心议题摘要 -
-
- - - -
-
-
-
-
-

成员发展与关怀

- 01 -
-

重点关注九年级成员发展,强调平衡学业、生活与工作室事务;关注成员身心健康,提醒做好冬季健康防护,体现以人为本的管理理念。

-
- 人员管理 - 成员关怀 -
-
-
-
- - -
-
-
-
-
-

组织架构改革 · 金字塔制 → 部门制

- 02 -
-

推进从金字塔式组委制到扁平化部门制的核心变革,明确各部门分工与职能边界。识别当前痛点:人手短缺、新部门建设滞后、执行效率不足,提出重启人才计划作为核心解决方案。

-
- 组织变革 - 架构优化 - 人才计划 -
-
-
-
- - -
-
-
-
-
-

部门重建与核心职能界定

- 03 -
-

系统梳理现有部门构成及缺岗情况,表彰工程部、美工部核心成员的突出贡献。明确各部门核心职能定位与跨部门协作规范,为后续规模化扩张奠定制度基础。

-
- 部门建设 - 协作规范 -
-
-
-
- - -
-
-
-
-
-

品牌建设与长期战略规划

- 04 -
-

探讨工作室名称定位、商标体系与企业文化内核建设;明确项目推进分工、质量保障标准及测试机制,系统优化 GERP 任务追踪、工作复盘与成员考核机制,提出新项目精细化推进与弹性时间管理方案。

-
- 品牌战略 - 质量管理 - GERP 优化 -
-
-
-
- - -
-
- -
-
- -
-
- - -
- - -
-
-
- -
-

- 部门制核心是分工明确,更是心往一处想 —— 工程部铸骨,美工部赋魂。 -

-

团队的价值不止于项目成果,更在于让每个成员在成长中遇见更好的自己。

-
-
- -
-
-

TermiNexus

-

六会金句 · 2025-10-25

-
-
-
-
- - -
-
-
- -
-

- 和而不同、创新为主、以人为本 -

-

- Wishing you eternal happiness and joy, and thank you for bringing vitality to the organization. -

-
-
- -
-
-

银河锋锐软件开发工作室

-

组织寄语 · 2025-10-25

-
-
-
-
- -
- - -
-
-

本次会议已形成正式文件

-

包含完整决议事项、部门任务分配及后续跟进计划

-
- -
- -
-
- - - - - - -
diff --git a/gvsds/__pycache__/auth.cpython-312.pyc b/gvsds/__pycache__/auth.cpython-312.pyc index 33045e5c..d946b8ed 100644 Binary files a/gvsds/__pycache__/auth.cpython-312.pyc and b/gvsds/__pycache__/auth.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc index 6edd544b..25df47a5 100644 Binary files a/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc index fcb259fc..53c7d7ea 100644 Binary files a/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/role_scope.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/role_scope.cpython-312.pyc index b88e839b..d9f97b0f 100644 Binary files a/gvsds/apps/gerp/__pycache__/role_scope.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/role_scope.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index 7b7b7b42..5e7c875e 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -12,7 +12,7 @@ from gvsds.apps.gerp.models import ( Tenant, CompanyInfo, Company, Department, Role, UserRole, RolePermission, Permission, Menu, UserABAC, ItemReference, Backpack, WorkOrder, Message, FileAsset, Password, UserIDCard, PersonalPage, - DepartmentSMTM, Workgroup, SimulatedTrigger, + DepartmentSMTM, Workgroup, WorkgroupRole, SimulatedTrigger, CashReceiptsPaymentsLedger, Asset, Liability, UserSettings, PositionTemplate, PositionTemplatePermission, Position, UserDept, AuditLog, @@ -94,6 +94,64 @@ def create_default_role_for_user(user, admin_user=None): pass +def create_tenant_admin_role(tenant): + """为租户(集团)创建"集团超级管理员"角色并分配 admin:tenant 权限。 + + 在租户审核通过时调用,确保每个集团都有一个租户内管理员角色。 + 该角色拥有 admin:tenant 权限: + - IsAdmin 判定为 True(关闭公司隔离,可跨公司管理本租户事务) + - IsSuperAdmin 判定为 False(保留租户隔离,仅管理本租户) + - IsTenantAdmin 判定为 True + + 与跨租户管理员(GERP 测试员,持有 admin:super)形成两级管理员体系。 + + Args: + tenant: Tenant 模型实例(需有 TenantUUID 属性) + + Returns: + Role 实例(已创建或已存在) + """ + import uuid as _uuid + from gvsds.apps.gerp.models import Role, Permission, RolePermission + + SYSTEM_COMPANY_UUID = _uuid.UUID('00000000-0000-0000-0000-000000000001').bytes + tenant_uuid = tenant.TenantUUID + if hasattr(tenant_uuid, 'bytes'): + tenant_uuid = tenant_uuid.bytes + + # 查找或创建"集团超级管理员"角色 + role = Role.objects.filter( + TenantUUID=tenant_uuid, + RoleName='集团超级管理员', + ).first() + if role: + return role + + role = Role.objects.create( + RoleUUID=_uuid.uuid4().bytes, + TenantUUID=tenant_uuid, + RoleName='集团超级管理员', + RoleStatus=1, + AssignScope=0, + RoleDesc='集团超级管理员,管理本租户下所有事务', + ) + + # 分配 admin:tenant 权限(租户内管理员:关闭公司隔离,保留租户隔离) + admin_perm = Permission.objects.filter( + PermCode='admin:tenant', + PermStatus=1, + ).first() + if admin_perm: + RolePermission.objects.create( + RolePermUUID=_uuid.uuid4().bytes, + RoleUUID=role.RoleUUID, + PermUUID=admin_perm.PermUUID, + CompanyUUID=SYSTEM_COMPANY_UUID, + ) + + return role + + def _pascalize_record(record, model=None): result = {} column_map = {} @@ -105,7 +163,11 @@ def _pascalize_record(record, model=None): column_map[f.attname] = f.db_column for key, val in record.items(): out_key = column_map.get(key, key) - if isinstance(val, bytes) and len(val) == 16: + # UUIDObj 处理:gvsdsdk UUIDField 返回 UUIDObj 对象(非 bytes), + # 需转为字符串以便 JSON 序列化,否则前端收不到数据 + if val is not None and val.__class__.__name__ == 'UUIDObj': + val = val.str + elif isinstance(val, bytes) and len(val) == 16: try: val = str(uuid.UUID(bytes=val)) except ValueError: @@ -146,6 +208,7 @@ class FormService: 'PersonalPage': PersonalPage, 'DepartmentSMTM': DepartmentSMTM, 'Workgroup': Workgroup, + 'WorkgroupRole': WorkgroupRole, 'SimulatedTrigger': SimulatedTrigger, 'CashReceiptsPaymentsLedger': CashReceiptsPaymentsLedger, 'Asset': Asset, @@ -216,22 +279,33 @@ class FormService: self.current_tenant_uuid = current_tenant_uuid else: self.current_tenant_uuid = current_tenant_uuid - # 强公司隔离:默认启用,仅当 admin_verify=True 且用户是管理员时才关闭 - if admin_verify and self._is_admin(user): + # 公司隔离:管理员(admin:super 或 admin:tenant)自动关闭,普通用户启用 + # 租户隔离:仅跨租户管理员(admin:super)关闭,租户内管理员(admin:tenant)和普通用户启用 + if self._is_super_admin(user): self.company_scope = False + self.tenant_scope = False + elif self._is_admin(user): + self.company_scope = False + self.tenant_scope = True else: self.company_scope = True + self.tenant_scope = True import logging - logging.info('[FormService] __init__: user=%s, company_scope=%s, current_company_uuid=%s, current_tenant_uuid=%s', - user, self.company_scope, + logging.info('[FormService] __init__: user=%s, company_scope=%s, tenant_scope=%s, current_company_uuid=%s, current_tenant_uuid=%s', + user, self.company_scope, self.tenant_scope, self.current_company_uuid.hex() if isinstance(self.current_company_uuid, bytes) else self.current_company_uuid, self.current_tenant_uuid.hex() if isinstance(self.current_tenant_uuid, bytes) else self.current_tenant_uuid) def _is_admin(self, user): - """判断用户是否为管理员(委托给 gvsdsdk.auth.admin_service.IsAdmin)""" + """判断用户是否为管理员(admin:super 或 admin:tenant,委托给 IsAdmin)""" from gvsdsdk.auth.admin_service import IsAdmin return IsAdmin(user=user, role_uuid=self.current_role_uuid) + def _is_super_admin(self, user): + """判断用户是否为跨租户管理员(仅 admin:super,委托给 IsSuperAdmin)""" + from gvsdsdk.auth.admin_service import IsSuperAdmin + return IsSuperAdmin(user=user, role_uuid=self.current_role_uuid) + def _build_field_map(self, model): pascal_map = {} for f in model._meta.get_fields(): @@ -279,11 +353,81 @@ class FormService: return result def _enrich_entity_labels(self, page_qs, ep_fields, records): - """为 records 中的 entityPicker 字段添加 _label 后缀字段,以及 extraFields""" + """为 records 中的 entityPicker 字段添加 _label 后缀字段,以及 extraFields + + 支持两种字段类型: + - ForeignKey:getattr(obj, field_name) 返回关联模型实例,直接取 label_field + - UUIDField:getattr(obj, field_name) 返回 UUIDObj,需批量查询关联表获取名称 + """ try: obj_list = list(page_qs) except Exception: return records + if not obj_list: + return records + + # 预构建 UUIDField 类型的 UUID → label 映射(批量查询优化性能) + # key: (entity_name, label_field), value: {uuid_bytes: label} + uuid_label_cache = {} + uuid_extra_cache = {} # key: (entity_name, extra_field), value: {uuid_bytes: value} + + for ep_info in ep_fields: + field_name = ep_info['field_name'] + entity_name = ep_info['entity_name'] + label_field = ep_info['label_field'] + extra_fields = ep_info.get('extra_fields', []) + value_field = ep_info['value_field'] + + # 判断字段类型 + field_obj = None + for f in obj_list[0]._meta.get_fields(): + if hasattr(f, 'name') and f.name == field_name: + field_obj = f + break + + # 仅对 UUIDField(非主键)类型批量查询;ForeignKey 通过 getattr 直接获取 + if not (field_obj and field_obj.__class__.__name__ == 'UUIDField' + and not getattr(field_obj, 'primary_key', False)): + continue + + # 收集所有 UUID 值(转为 bytes) + uuid_values = set() + for obj in obj_list: + val = getattr(obj, field_name, None) + if val is not None: + if hasattr(val, 'bytes'): + uuid_values.add(val.bytes) + elif isinstance(val, str): + try: + uuid_values.add(uuid.UUID(val).bytes) + except ValueError: + pass + + if not uuid_values: + continue + + related_model = self.TABLE_MAP.get(entity_name) + if not related_model: + continue + + # 批量查询关联表 + try: + related_qs = related_model.objects.filter(**{f'{value_field}__in': uuid_values}) + for r in related_qs: + pk_val = getattr(r, value_field, None) + pk_bytes = pk_val.bytes if hasattr(pk_val, 'bytes') else pk_val + if pk_bytes is None: + continue + label = getattr(r, label_field, None) + if label is not None: + uuid_label_cache.setdefault((entity_name, label_field), {})[pk_bytes] = label + for ef in extra_fields: + ef_val = getattr(r, ef, None) + if ef_val is not None: + uuid_extra_cache.setdefault((entity_name, ef), {})[pk_bytes] = ef_val + except Exception: + pass + for i, obj in enumerate(obj_list): if i >= len(records): break @@ -291,25 +435,54 @@ class FormService: field_name = ep_info['field_name'] label_field = ep_info['label_field'] extra_fields = ep_info.get('extra_fields', []) + entity_name = ep_info['entity_name'] + + # 判断字段类型 + field_obj = None + for f in obj._meta.get_fields(): + if hasattr(f, 'name') and f.name == field_name: + field_obj = f + break + try: - related_obj = getattr(obj, field_name, None) - if related_obj is not None: - # 获取 db_column 作为 key 前缀 - db_col = None - for f in obj._meta.get_fields(): - if hasattr(f, 'name') and f.name == field_name and hasattr(f, 'db_column'): - db_col = f.db_column or f.name - break - key_prefix = db_col or field_name - # label 字段 - label = getattr(related_obj, label_field, None) + # 获取 db_column 作为 key 前缀 + db_col = None + for f in obj._meta.get_fields(): + if hasattr(f, 'name') and f.name == field_name and hasattr(f, 'db_column'): + db_col = f.db_column or f.name + break + key_prefix = db_col or field_name + + is_uuid_field = (field_obj + and field_obj.__class__.__name__ == 'UUIDField' + and not getattr(field_obj, 'primary_key', False)) + + if is_uuid_field: + # UUIDField 类型:从缓存查找 + val = getattr(obj, field_name, None) + if val is None: + continue + val_bytes = val.bytes if hasattr(val, 'bytes') else val + if val_bytes is None: + continue + label = uuid_label_cache.get((entity_name, label_field), {}).get(val_bytes) if label is not None: records[i][key_prefix + '_label'] = str(label) - # extraFields 字段 for ef in extra_fields: - ef_val = getattr(related_obj, ef, None) + ef_val = uuid_extra_cache.get((entity_name, ef), {}).get(val_bytes) if ef_val is not None: records[i][key_prefix + '_' + ef] = ef_val + else: + # ForeignKey 类型:直接获取关联实例 + related_obj = getattr(obj, field_name, None) + if related_obj is not None: + label = getattr(related_obj, label_field, None) + if label is not None: + records[i][key_prefix + '_label'] = str(label) + for ef in extra_fields: + ef_val = getattr(related_obj, ef, None) + if ef_val is not None: + records[i][key_prefix + '_' + ef] = ef_val except Exception: pass return records @@ -605,19 +778,13 @@ class FormService: except Exception: return {'records': [], 'total': 0} - # 强公司隔离:非管理员自动启用,仅返回当前公司/租户数据 + # 公司隔离:admin:super/admin:tenant 关闭,普通用户启用 + # 租户隔离:仅 admin:super 关闭,admin:tenant/普通用户启用 # 系统级表(Menu/Permission)豁免隔离 - if self.company_scope and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: - field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} - # 租户隔离:Tenant 表按 TenantUUID 过滤,只看自己所属租户 - if table_name == 'Tenant': - if not self.current_tenant_uuid: - import logging - logging.warning('[FormService] Tenant query but current_tenant_uuid is None, user=%s', self.user) - return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20} - qs = qs.filter(TenantUUID=self.current_tenant_uuid) + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: # 公司隔离:有 CompanyUUID 字段的表按当前公司过滤 - elif 'CompanyUUID' in field_names: + if self.company_scope and 'CompanyUUID' in field_names: if not self.current_company_uuid: # 无公司上下文时返回空结果 import logging @@ -634,22 +801,38 @@ class FormService: else: # UUIDField(包括 Company 表自身的主键) qs = qs.filter(CompanyUUID=self.current_company_uuid) - # 租户隔离:有 TenantUUID 字段但无 CompanyUUID 的表(Role/CompanyInfo 等)按租户过滤 - elif 'TenantUUID' in field_names: + # 租户隔离:admin:super 关闭,admin:tenant/普通用户启用 + # 覆盖三种表结构: + # 1. Tenant 表自身:按 TenantUUID 主键过滤 + # 2. 有 TenantUUID 字段的表(Role/CompanyInfo 等):直接按 TenantUUID 过滤 + # 3. 有 CompanyUUID ForeignKey 但无 TenantUUID 的表(Department/Workgroup): + # 通过 Company 关联跨表过滤到当前租户 + elif self.tenant_scope and (table_name == 'Tenant' or 'TenantUUID' in field_names or 'CompanyUUID' in field_names): if not self.current_tenant_uuid: import logging logging.warning('[FormService] tenant scope but current_tenant_uuid is None, user=%s, table=%s', self.user, table_name) return {'records': [], 'total': 0, 'page': 1, 'PageSize': 20} - # 判断 TenantUUID 字段类型:ForeignKey 用 _id 后缀 - tenant_field = None - for f in model._meta.get_fields(): - if hasattr(f, 'name') and f.name == 'TenantUUID': - tenant_field = f - break - if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey': - qs = qs.filter(TenantUUID_id=self.current_tenant_uuid) + if table_name == 'Tenant' or 'TenantUUID' in field_names: + # 表自身有 TenantUUID 字段:直接过滤 + tenant_field = None + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'TenantUUID': + tenant_field = f + break + if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey': + qs = qs.filter(TenantUUID_id=self.current_tenant_uuid) + else: + qs = qs.filter(TenantUUID=self.current_tenant_uuid) else: - qs = qs.filter(TenantUUID=self.current_tenant_uuid) + # 有 CompanyUUID ForeignKey 但无 TenantUUID:通过 Company 关联跨表过滤 + company_field = None + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'CompanyUUID': + company_field = f + break + if company_field and company_field.__class__.__name__ == 'ForeignKey': + qs = qs.filter(CompanyUUID__TenantUUID=self.current_tenant_uuid) + # UUIDField 类型的 CompanyUUID(如 Company 表自身)必有 TenantUUID 字段,已走上面分支 # 角色作用域过滤:针对 User/UserRole/Order 表按角色差异化可见范围 # admin 看全部,dept_manager 看本部门,workgroup_lead 看本工作组,member 只看自己 @@ -711,10 +894,21 @@ class FormService: total = qs.count() start = (page - 1) * page_size # 识别 entityPicker 字段,使用 select_related 预加载关联实体 + # 注意:select_related 只能用于 ForeignKey,UUIDField 类型的 entityPicker 不能预加载 ep_fields = self._get_entity_picker_fields(model) if ep_fields: - fk_names = [f['field_name'] for f in ep_fields - if f['field_name'] not in ('RoleUUID', 'PermUUID', 'PositionTemplateUUID', 'PositionUUID')] + excluded = {'RoleUUID', 'PermUUID', 'PositionTemplateUUID', 'PositionUUID'} + fk_names = [] + for ep_info in ep_fields: + fn = ep_info['field_name'] + if fn in excluded: + continue + # 只对 ForeignKey 字段使用 select_related,避免 UUIDField 触发 FieldError + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == fn: + if f.__class__.__name__ == 'ForeignKey': + fk_names.append(fn) + break if fk_names: qs = qs.select_related(*fk_names) page_qs = qs[start:start + page_size] @@ -765,10 +959,10 @@ class FormService: is_fk = field.__class__.__name__ == 'ForeignKey' attr_name = field.attname if is_fk else resolved create_data[attr_name] = val - # 强公司隔离:当启用 company_scope 且当前角色有公司上下文时, - # 自动注入当前公司 UUID(若模型有 CompanyUUID 字段且未显式提供) + # 自动填充当前公司 UUID:若模型有 CompanyUUID 字段且前端未显式提供, + # 默认使用当前角色对应的公司(适用于所有用户,包括管理员)。 # 系统级表(Menu/Permission/Tenant/Company 等)豁免,不自动注入公司 - if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + if self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} if 'CompanyUUID' in field_names: pk_field = model._meta.pk @@ -791,6 +985,22 @@ class FormService: create_data['CompanyUUID_id'] = self.current_company_uuid else: create_data['CompanyUUID'] = self.current_company_uuid + # 强租户隔离:当启用 tenant_scope 且当前角色有租户上下文时, + # 强制注入当前租户 UUID(覆盖用户传入值,防跨租户注入) + # 系统级表(Menu/Permission)豁免;admin:super(tenant_scope=False)不注入,允许跨租户 + if self.tenant_scope and self.current_tenant_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + if 'TenantUUID' in field_names: + # 判断 TenantUUID 字段类型:ForeignKey 用 _id,UUIDField 用字段名 + tenant_field = None + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'TenantUUID': + tenant_field = f + break + if tenant_field and tenant_field.__class__.__name__ == 'ForeignKey': + create_data['TenantUUID_id'] = self.current_tenant_uuid + else: + create_data['TenantUUID'] = self.current_tenant_uuid pk_field_name = model._meta.pk.name if model._meta.pk else None if pk_field_name and pk_field_name not in create_data: pk_field = model._meta.pk @@ -838,13 +1048,38 @@ class FormService: pk_field_name = model._meta.pk.name pk_field = model._meta.pk pk_value = self._convert_value_for_field(pk_field, pk) + # 构造查询条件:附加租户/公司过滤防止跨域越权 + lookup = {pk_field_name: pk_value} + if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + # 租户隔离:tenant_scope 启用时附加 TenantUUID 过滤(防跨租户改写) + if self.tenant_scope and self.current_tenant_uuid and 'TenantUUID' in field_names: + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'TenantUUID': + if f.__class__.__name__ == 'ForeignKey': + lookup['TenantUUID_id'] = self.current_tenant_uuid + else: + lookup['TenantUUID'] = self.current_tenant_uuid + break + # 公司隔离:company_scope 启用时附加 CompanyUUID 过滤(防跨公司改写) + elif self.company_scope and self.current_company_uuid and 'CompanyUUID' in field_names: + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'CompanyUUID': + if f.__class__.__name__ == 'ForeignKey': + lookup['CompanyUUID_id'] = self.current_company_uuid + else: + lookup['CompanyUUID'] = self.current_company_uuid + break try: - obj = model.objects.get(**{pk_field_name: pk_value}) + obj = model.objects.get(**lookup) except model.DoesNotExist: return None - # 强公司隔离:company_scope 启用时校验目标记录归属当前公司(系统级表豁免) - if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES and not self._check_company_owner(obj): - return None + # 归属校验:company_scope 或 tenant_scope 任一启用时校验(系统级表豁免) + if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj): + return None + if self.tenant_scope and self.current_tenant_uuid and not self._check_tenant_owner(obj): + return None # 审计日志:修改前捕获旧数据快照 old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None pascal_map = self._build_field_map(model) @@ -857,6 +1092,10 @@ class FormService: continue if resolved == model._meta.pk.name: continue + # 保护归属字段:tenant_scope 启用时禁止改写 TenantUUID/CompanyUUID(防数据迁移攻击) + # admin:super(tenant_scope=False)不受此限制,可做跨租户数据迁移 + if self.tenant_scope and resolved in ('TenantUUID', 'CompanyUUID') and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + continue field = pascal_map.get(resolved) or pascal_map.get(key) if field is None: continue @@ -897,13 +1136,38 @@ class FormService: pk_field_name = model._meta.pk.name pk_field = model._meta.pk pk_value = self._convert_value_for_field(pk_field, pk) + # 构造查询条件:附加租户/公司过滤防止跨域越权 + lookup = {pk_field_name: pk_value} + if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')} + # 租户隔离:tenant_scope 启用时附加 TenantUUID 过滤(防跨租户删除) + if self.tenant_scope and self.current_tenant_uuid and 'TenantUUID' in field_names: + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'TenantUUID': + if f.__class__.__name__ == 'ForeignKey': + lookup['TenantUUID_id'] = self.current_tenant_uuid + else: + lookup['TenantUUID'] = self.current_tenant_uuid + break + # 公司隔离:company_scope 启用时附加 CompanyUUID 过滤(防跨公司删除) + elif self.company_scope and self.current_company_uuid and 'CompanyUUID' in field_names: + for f in model._meta.get_fields(): + if hasattr(f, 'name') and f.name == 'CompanyUUID': + if f.__class__.__name__ == 'ForeignKey': + lookup['CompanyUUID_id'] = self.current_company_uuid + else: + lookup['CompanyUUID'] = self.current_company_uuid + break try: - obj = model.objects.get(**{pk_field_name: pk_value}) + obj = model.objects.get(**lookup) except model.DoesNotExist: return False - # 强公司隔离:company_scope 启用时校验目标记录归属当前公司(系统级表豁免) - if self.company_scope and self.current_company_uuid and table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES and not self._check_company_owner(obj): - return False + # 归属校验:company_scope 或 tenant_scope 任一启用时校验(系统级表豁免) + if table_name not in self.COMPANY_SCOPE_EXEMPT_TABLES: + if self.company_scope and self.current_company_uuid and not self._check_company_owner(obj): + return False + if self.tenant_scope and self.current_tenant_uuid and not self._check_tenant_owner(obj): + return False # 审计日志:删除前捕获旧数据快照和目标 UUID old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None _audit_target_uuid = obj.pk if table_name in self.AUDIT_TABLES else None @@ -947,7 +1211,7 @@ class FormService: def _check_company_owner(self, obj): """校验对象是否归属当前公司(用于 company_scope 下的修改/删除保护)。 - 若模型无 CompanyUUID 字段则放行(不隔离)。 + 若模型无 CompanyUUID 字段则放行(依赖 _check_tenant_owner 或外层 lookup 过滤)。 CompanyUUID 可能是外键(Department/Workgroup)或主键(Company 本身)。""" try: field_names = {f.name for f in obj._meta.get_fields() if hasattr(f, 'name')} @@ -962,7 +1226,20 @@ class FormService: obj_company = getattr(obj, 'CompanyUUID_id', None) or getattr(obj, 'CompanyUUID', None) return obj_company == self.current_company_uuid except Exception: - return True + return False # 异常时 fail-closed(拒绝),避免吞异常后越权 + + def _check_tenant_owner(self, obj): + """校验对象是否归属当前租户(用于 tenant_scope 下的修改/删除保护)。 + 若模型无 TenantUUID 字段则放行(依赖外层 lookup 过滤或 _check_company_owner)。 + TenantUUID 可能是外键或 UUIDField。""" + try: + field_names = {f.name for f in obj._meta.get_fields() if hasattr(f, 'name')} + if 'TenantUUID' not in field_names: + return True + obj_tenant = getattr(obj, 'TenantUUID_id', None) or getattr(obj, 'TenantUUID', None) + return obj_tenant == self.current_tenant_uuid + except Exception: + return False # 异常时 fail-closed(拒绝) def _get_record_snapshot(self, obj): """将模型实例转为可 JSON 序列化的字典(用于审计日志数据快照)""" diff --git a/gvsds/apps/gerp/migrations/0013_workgroup_role.py b/gvsds/apps/gerp/migrations/0013_workgroup_role.py new file mode 100644 index 00000000..90280357 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0013_workgroup_role.py @@ -0,0 +1,35 @@ +"""Create WorkgroupRole table: 工作组与角色多对多关联 + +工作组只绑定角色,不直接绑定用户。用户通过持有该角色即成为工作组成员。 +手动创建此迁移以避免 makemigrations 检测到其他模型的预存字段名冲突。 +""" +import datetime +import django.db.models.deletion +import gvsds.apps.core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0012_doc_editor_menu'), + ] + + operations = [ + migrations.CreateModel( + name='WorkgroupRole', + fields=[ + ('WorkgroupRoleUUID', gvsds.apps.core.models.UUIDField(db_column='WorkgroupRoleUUID', primary_key=True, serialize=False, verbose_name='关联UUID')), + ('RoleUUID', gvsds.apps.core.models.UUIDField(db_column='RoleUUID', verbose_name='角色UUID')), + ('RoleType', models.SmallIntegerField(db_column='RoleType', default=0, verbose_name='角色类型')), + ('CreateTime', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ('WorkgroupUUID', models.ForeignKey(db_column='WorkgroupUUID', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='gerp.workgroup', verbose_name='工作组UUID')), + ('CompanyUUID', models.ForeignKey(db_column='CompanyUUID', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='gerp.company', verbose_name='公司UUID')), + ], + options={ + 'db_table': 'WorkgroupRole', + 'verbose_name': '工作组角色关联', + 'verbose_name_plural': '工作组角色关联', + }, + ), + ] diff --git a/gvsds/apps/gerp/migrations/__pycache__/0013_workgroup_role.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0013_workgroup_role.cpython-312.pyc new file mode 100644 index 00000000..4daaaa35 Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0013_workgroup_role.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index b81b1b00..124c4d3c 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -311,6 +311,28 @@ class Workgroup(QModel): verbose_name_plural = '工作组' +class WorkgroupRole(QModel): + """工作组角色关联表——工作组与角色的多对多映射 + + 工作组只绑定角色,不直接绑定用户。用户通过持有该角色即成为工作组成员。 + RoleType 标识该角色在工作组中的类型:0=组员,1=组长。 + CompanyUUID 冗余存储用于 SAAS 数据隔离过滤(与 UserRole 表设计一致)。 + """ + + WorkgroupRoleUUID = UUIDField(primary_key=True, verbose_name='关联UUID') + WorkgroupUUID = models.ForeignKey('Workgroup', on_delete=models.CASCADE, related_name='+', db_column='WorkgroupUUID', verbose_name='工作组UUID') + RoleUUID = UUIDField(verbose_name='角色UUID') + CompanyUUID = models.ForeignKey('Company', on_delete=models.CASCADE, related_name='+', db_column='CompanyUUID', verbose_name='公司UUID') + RoleType = models.SmallIntegerField(default=0, verbose_name='角色类型') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'WorkgroupRole' + app_label = 'gerp' + verbose_name = '工作组角色关联' + verbose_name_plural = '工作组角色关联' + + class ItemReference(QModel): """物品引用表——物品模板定义""" diff --git a/gvsds/apps/gerp/role_scope.py b/gvsds/apps/gerp/role_scope.py index 6a70f3b3..eb799ed5 100644 --- a/gvsds/apps/gerp/role_scope.py +++ b/gvsds/apps/gerp/role_scope.py @@ -2,7 +2,7 @@ 判断当前用户的角色作用域(管理员/部门负责人/组长/组员),用于数据权限过滤。 作用域类型: - admin —— 管理员(IsSuperuser 或 admin:super 权限),看全公司 + admin —— 管理员(当前角色拥有 admin:super 或 admin:tenant 权限),看全公司 dept_manager —— 部门负责人(UserRole.DeptUUID 非空 + RoleType=1),看本部门成员 workgroup_lead —— 工作组长(UserRole.WorkgroupUUID 非空 + RoleType=1),看本工作组成员 member —— 普通组员,只看自己 diff --git a/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc index cc3fd81c..a353a988 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/menu.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/menu.cpython-312.pyc index bdad13b4..6c4b8da0 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/menu.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/menu.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/order_flow.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/order_flow.cpython-312.pyc index 188ef245..255a35bf 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/order_flow.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/order_flow.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/role_permission.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/role_permission.cpython-312.pyc index 14c41f9c..4aa98ce7 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/role_permission.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/role_permission.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/tenant_review.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/tenant_review.cpython-312.pyc index 2178065d..8693a563 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/tenant_review.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/tenant_review.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc index 1892f991..ecda980a 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/form_api.py b/gvsds/apps/gerp/views/form_api.py index 59be992e..89b71351 100644 --- a/gvsds/apps/gerp/views/form_api.py +++ b/gvsds/apps/gerp/views/form_api.py @@ -1,6 +1,7 @@ import logging import uuid as _uuid from rest_framework.response import Response +from gvsdsdk.model_utils import UUIDObj from . import ELTAuthenticatedAPIView from gvsds.apps.gerp.form_service import FormService @@ -28,11 +29,20 @@ def _get_current_context(request): def _is_admin(request): - """判断用户是否为管理员(委托给 gvsdsdk.auth.admin_service.IsAdmin)""" + """判断用户是否为管理员(admin:super 或 admin:tenant,委托给 IsAdmin)""" from gvsdsdk.auth.admin_service import IsAdmin return IsAdmin(request) +def _is_super_admin(request): + """判断用户是否为跨租户管理员(仅 admin:super,委托给 IsSuperAdmin) + + 用于租户隔离判定:仅 admin:super 关闭租户隔离。 + """ + from gvsdsdk.auth.admin_service import IsSuperAdmin + return IsSuperAdmin(request) + + def _make_service(request, user): """构造 FormService,自动传入当前公司 UUID、角色 UUID 和租户 UUID。 上下文来自 CurrentRoleMiddleware 注入的 request 属性。 @@ -48,9 +58,9 @@ def _make_service(request, user): ip_address = x_forwarded_for.split(',')[0].strip() else: ip_address = request.META.get('REMOTE_ADDR', '') - # 计算角色作用域(管理员特权模式下不应用作用域过滤) + # 计算角色作用域(管理员不应用作用域过滤) role_scope = None - if not (admin_verify and _is_admin(request)): + if not _is_admin(request): from gvsds.apps.gerp.role_scope import GetUserScope try: role_scope = GetUserScope(request) @@ -80,23 +90,25 @@ _ACTION_PERM_SUFFIX = { def _check_rbac_permission(table_name, action, request): """RBAC 权限码校验:构造 {Table}:{suffix} 权限码,检查当前角色是否拥有。 - 向后兼容:若该权限码在 Permission 表中不存在,返回 None(回退到原有逻辑)。 - 若权限码存在但当前角色未分配,返回 403 错误响应。 + 返回 (error_response, perm_defined): + - perm_defined=False: 权限码未定义,调用方应回退到原有逻辑 + - perm_defined=True, error_response=None: 权限码存在且当前角色已分配 → 放行 + - perm_defined=True, error_response=Response: 权限码存在但当前角色未分配 → 拒绝 """ suffix = _ACTION_PERM_SUFFIX.get(action) if not suffix: - return None + return None, False perm_code = f'{table_name}:{suffix}' from gvsds.apps.gerp.models import Permission, RolePermission # 检查权限码是否在系统中定义 perm_qs = Permission.objects.filter(PermCode=perm_code, PermStatus=1) if not perm_qs.exists(): - return None # 权限码未定义,回退到原有逻辑 + return None, False # 权限码未定义,回退到原有逻辑 perm_uuids = list(perm_qs.values_list('PermUUID', flat=True)) # 检查当前角色是否拥有该权限 current_role_uuid, current_company_uuid, _ = _get_current_context(request) if not current_role_uuid: - return Response({'code': 403, 'msg': f'无权操作:缺少权限 {perm_code}(未指定当前角色)'}, status=403) + return Response({'code': 403, 'msg': f'无权操作:缺少权限 {perm_code}(未指定当前角色)'}, status=403), True rp_qs = RolePermission.objects.filter( RoleUUID=current_role_uuid, PermUUID__in=perm_uuids, @@ -104,8 +116,8 @@ def _check_rbac_permission(table_name, action, request): if current_company_uuid: rp_qs = rp_qs.filter(CompanyUUID=current_company_uuid) if not rp_qs.exists(): - return Response({'code': 403, 'msg': f'无权操作:当前角色未分配权限 {perm_code}'}, status=403) - return None + return Response({'code': 403, 'msg': f'无权操作:当前角色未分配权限 {perm_code}'}, status=403), True + return None, True def _check_table_access(table_name, action, request): @@ -113,16 +125,23 @@ def _check_table_access(table_name, action, request): 校验顺序: 1. 管理员直接放行 - 2. RBAC 权限码校验(若权限码已定义则强制校验,未定义则跳过) - 3. ADMIN_ONLY_TABLES 黑名单 - 4. READONLY_TABLES_FOR_NORMAL 只读约束 + 2. RBAC 权限码校验: + - 权限码已定义 → RBAC 校验优先(通过则放行,未通过则拒绝,跳过黑名单/只读约束) + - 权限码未定义 → 回退到黑名单/只读约束 + 3. ADMIN_ONLY_TABLES 黑名单(仅权限码未定义时生效) + 4. READONLY_TABLES_FOR_NORMAL 只读约束(仅权限码未定义时生效) + + 设计意图:当管理员为某表配置了 RBAC 权限码并分配给公司负责人等角色时, + 该角色即可在权限范围内(受公司隔离限制)操作该表,不再受 READONLY 硬编码约束。 """ if _is_admin(request): return None - # RBAC 权限码校验(向后兼容:权限码未定义时返回 None 不阻断) - rbac_error = _check_rbac_permission(table_name, action, request) - if rbac_error: + # RBAC 权限码校验 + rbac_error, perm_defined = _check_rbac_permission(table_name, action, request) + if perm_defined: + # RBAC 驱动模式:权限码已定义,RBAC 校验结果决定是否放行 return rbac_error + # 回退模式:权限码未定义,使用原有黑名单/只读约束 if table_name in FormService.ADMIN_ONLY_TABLES: return Response({'code': 403, 'msg': f'无权访问表 {table_name},仅管理员可操作'}, status=403) if table_name in FormService.READONLY_TABLES_FOR_NORMAL: @@ -331,7 +350,7 @@ class FormAPIView(ELTAuthenticatedAPIView): class CheckAdminView(ELTAuthenticatedAPIView): - """检查当前用户是否具有管理员特权(超级用户或当前角色拥有 admin:super 权限) + """检查当前用户是否具有管理员特权(超级用户或当前角色拥有 admin:super 或 admin:tenant 权限) 额外返回 role_scope 信息,用于前端按角色作用域差异化显示: - admin: 管理员(看全公司) @@ -352,6 +371,7 @@ class CheckAdminView(ELTAuthenticatedAPIView): user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None is_superuser = bool(user and getattr(user, 'IsSuperuser', False)) is_admin = _is_admin(request) + is_super_admin = _is_super_admin(request) current_role_uuid, current_company_uuid, current_tenant_uuid = _get_current_context(request) # 获取角色名 role_name = '' @@ -363,9 +383,11 @@ class CheckAdminView(ELTAuthenticatedAPIView): role_name = role.RoleName or '' except Exception: pass - # 判断隔离状态(与 _make_service 一致) - admin_verify = request.query_params.get('admin_verify') == '1' if hasattr(request, 'query_params') else False - company_scope = not (admin_verify and is_admin) + # 判断隔离状态(与 FormService.__init__ 一致) + # company_scope: admin:super/admin:tenant 关闭,普通用户启用 + # tenant_scope: 仅 admin:super 关闭,admin:tenant/普通用户启用 + company_scope = not is_admin + tenant_scope = not is_super_admin # 计算角色作用域(前端差异化显示依据) scope_type = 'member' @@ -378,9 +400,9 @@ class CheckAdminView(ELTAuthenticatedAPIView): scope_type = scope.Type scope_label = self.SCOPE_TYPE_LABEL.get(scope_type, '普通组员') if scope.DeptUUID: - dept_uuid_str = str(_uuid.UUID(bytes=scope.DeptUUID)) + dept_uuid_str = UUIDObj(scope.DeptUUID).str if scope.WorkgroupUUID: - workgroup_uuid_str = str(_uuid.UUID(bytes=scope.WorkgroupUUID)) + workgroup_uuid_str = UUIDObj(scope.WorkgroupUUID).str except Exception: pass @@ -389,12 +411,14 @@ class CheckAdminView(ELTAuthenticatedAPIView): 'data': { 'is_admin': is_admin, 'is_superuser': is_superuser, + 'is_super_admin': is_super_admin, 'current_role_name': role_name, - 'current_role_uuid': str(_uuid.UUID(bytes=current_role_uuid)) if current_role_uuid else None, - 'current_company_uuid': str(_uuid.UUID(bytes=current_company_uuid)) if current_company_uuid else None, - 'current_tenant_uuid': str(_uuid.UUID(bytes=current_tenant_uuid)) if current_tenant_uuid else None, + '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, + 'current_tenant_uuid': UUIDObj(current_tenant_uuid).str if current_tenant_uuid else None, 'has_current_company': current_company_uuid is not None, 'company_scope': company_scope, + 'tenant_scope': tenant_scope, # 角色作用域(前端差异化显示依据) 'role_scope': { 'Type': scope_type, diff --git a/gvsds/apps/gerp/views/menu.py b/gvsds/apps/gerp/views/menu.py index a39a1d9b..3d5444cc 100644 --- a/gvsds/apps/gerp/views/menu.py +++ b/gvsds/apps/gerp/views/menu.py @@ -4,7 +4,7 @@ 差异化显示策略(按优先级从高到低): 1. 公司过滤:公有菜单(CompanyUUID 为空)+ 当前公司私有菜单(对所有用户生效,包括管理员) 2. 祖先链校验:任何祖先不属于当前公司则整个分支移除(对所有用户生效) - 3. 管理员特权:IsSuperuser 或拥有 admin:super 权限的用户跳过权限过滤 + 3. 管理员特权:当前角色拥有 admin:super 或 admin:tenant 权限的用户跳过权限过滤 4. 权限过滤(仅非管理员):按 PermissionAttr 权限码校验,父节点无权限则整个子树隐藏 管理 API(仅管理员可访问): @@ -59,15 +59,18 @@ class MenuListCompatView(ELTAuthenticatedAPIView): role_uuid_bytes = None # 获取当前公司上下文(通过 UserRole.CompanyUUID) + # UserRole.CompanyUUID 是 UUIDField(返回 UUIDObj),不能 select_related current_company = None if role_uuid_bytes: try: user_role = UserRole.objects.filter( UserUUID=user.UserUUID, RoleUUID=role_uuid_bytes, - ).select_related('CompanyUUID').first() + ).first() if user_role and user_role.CompanyUUID: - current_company = user_role.CompanyUUID + # 转为 bytes,用于后续 Menu.CompanyUUID(ForeignKey)过滤 + cc = user_role.CompanyUUID + current_company = cc.bytes if hasattr(cc, 'bytes') else cc except Exception: logger.exception('MenuListCompatView 解析当前公司失败') @@ -114,7 +117,8 @@ class MenuListCompatView(ELTAuthenticatedAPIView): # 祖先链校验:任何祖先不属于当前公司 → 整个分支移除 # 对所有用户生效(包括管理员),防止公有子节点挂在私有父节点下导致结构泄漏 - current_company_uuid_str = str(current_company.CompanyUUID) if current_company else None + # current_company 已是 bytes(来自 UserRole.CompanyUUID),转为 UUID 字符串用于匹配 + current_company_uuid_str = str(_uuid.UUID(bytes=current_company)) if current_company else None invalid_uuids = set() for uuid_str in list(menu_map.keys()): if uuid_str in invalid_uuids: diff --git a/gvsds/apps/gerp/views/order_flow.py b/gvsds/apps/gerp/views/order_flow.py index 48dc35e9..4203d8ab 100644 --- a/gvsds/apps/gerp/views/order_flow.py +++ b/gvsds/apps/gerp/views/order_flow.py @@ -121,9 +121,9 @@ class OrderFlowView(ELTAuthenticatedAPIView): # 解析上下文 _, current_company_uuid, current_tenant_uuid = _get_current_context(request) - admin_verify = request.query_params.get('admin_verify') == '1' - # 管理员特权模式下不传 company_uuid(让 OrderManager 跨公司查询) - effective_company_uuid = None if (admin_verify and _is_admin(request)) else current_company_uuid + # 管理员(admin:super 或 admin:tenant)自动关闭公司隔离,跨公司查询 + # 注意:OrderManager 按 tenant_uuid 必需过滤,两种管理员都只看本租户订单 + effective_company_uuid = None if _is_admin(request) else current_company_uuid # 先查询订单(用于审计快照) order_uuid_bytes = _to_uuid_bytes(order_uuid) diff --git a/gvsds/apps/gerp/views/role_permission.py b/gvsds/apps/gerp/views/role_permission.py index e0ab468c..0a1f8677 100644 --- a/gvsds/apps/gerp/views/role_permission.py +++ b/gvsds/apps/gerp/views/role_permission.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from . import ELTAuthenticatedAPIView from gvsds.apps.gerp.models import Role, Permission, RolePermission, Company -from gvsds.apps.gerp.views.form_api import _is_admin, _get_current_context +from gvsds.apps.gerp.views.form_api import _is_admin, _is_super_admin, _get_current_context logger = logging.getLogger(__name__) @@ -62,7 +62,7 @@ class RolePermissionsTreeView(ELTAuthenticatedAPIView): if not role_uuid: return Response({'code': 1, 'msg': 'RoleUUID 格式无效'}, status=400) - # 权限校验:仅管理员或当前角色拥有 admin:super 权限可分配 + # 权限校验:仅管理员(admin:super 或 admin:tenant)可分配 if not _is_admin(request): return Response({'code': 403, 'msg': '无权操作:仅管理员可分配角色权限'}, status=403) @@ -75,9 +75,9 @@ class RolePermissionsTreeView(ELTAuthenticatedAPIView): if not company_uuid: return Response({'code': 1, 'msg': '缺少 CompanyUUID 参数(无法确定权限分配的公司维度)'}, status=400) - # 查询权限(按租户过滤;管理员特权模式下可看全部) + # 查询权限(按租户过滤;仅跨租户管理员 admin:super 看全部,admin:tenant 保留租户隔离) perm_qs = Permission.objects.filter(PermStatus=1) - if tenant_uuid_bytes and not (request.query_params.get('admin_verify') == '1' and _is_admin(request)): + if tenant_uuid_bytes and not _is_super_admin(request): perm_qs = perm_qs.filter(TenantUUID=tenant_uuid_bytes) perm_qs = perm_qs.order_by('PermCategory', 'PermName') diff --git a/gvsds/apps/gerp/views/tenant_review.py b/gvsds/apps/gerp/views/tenant_review.py index bf35f9df..6e57621a 100644 --- a/gvsds/apps/gerp/views/tenant_review.py +++ b/gvsds/apps/gerp/views/tenant_review.py @@ -19,21 +19,15 @@ REVIEW_STATUS_PENDING = 0 REVIEW_STATUS_APPROVED = 1 REVIEW_STATUS_REJECTED = 2 -# 允许访问审核工作台的角色名集合 -REVIEW_ALLOWED_ROLE_NAMES = {'GERP 测试员'} - def _is_reviewer(request): - """判断用户是否有审核权限:管理员(IsAdmin)或当前角色名在允许集合中""" - from gvsdsdk.auth.admin_service import IsAdmin - # 管理员(超级用户/员工/admin:super 权限)直接放行 - if IsAdmin(request): - return True - current_role = getattr(request, 'current_role', None) - if current_role and hasattr(current_role, 'RoleName'): - if current_role.RoleName in REVIEW_ALLOWED_ROLE_NAMES: - return True - return False + """判断用户是否有审核权限:仅依据当前角色的 admin:super 权限(跨租户管理员) + + 租户审核是跨租户的系统级操作,仅 admin:super(如 GERP 测试员)可审核。 + admin:tenant(租户内管理员)无审核权限。 + """ + from gvsdsdk.auth.admin_service import IsSuperAdmin + return IsSuperAdmin(request) class TenantApplyView(ELTAuthenticatedAPIView): @@ -102,7 +96,7 @@ class TenantPendingListView(ELTAuthenticatedAPIView): class TenantApproveView(ELTAuthenticatedAPIView): - """批准租户入驻——仅激活租户(ReviewStatus=1),不自动创建公司/角色""" + """批准租户入驻——激活租户并自动创建"集团超级管理员"角色""" permission_classes = [IsAuthenticated] @@ -123,7 +117,16 @@ class TenantApproveView(ELTAuthenticatedAPIView): tenant.ReviewerUUID = getattr(request.user, 'UserUUID', None) tenant.ReviewTime = datetime.datetime.utcnow() tenant.save(update_fields=['ReviewStatus', 'ReviewerUUID', 'ReviewTime']) - return Response({'code': 0, 'msg': '审核通过,租户已激活'}) + # 审核通过后自动创建租户管理员角色 + role_name = '管理员角色' + try: + from ..form_service import create_tenant_admin_role + role = create_tenant_admin_role(tenant) + if role: + role_name = getattr(role, 'RoleName', None) or role_name + except Exception: + logger.exception('审核通过后创建租户管理员角色失败 tenant=%s', tenant.TenantUUID) + return Response({'code': 0, 'msg': f'审核通过,租户已激活,已创建{role_name}'}) class TenantRejectView(ELTAuthenticatedAPIView): diff --git a/gvsds/apps/gerp/views/viewsets.py b/gvsds/apps/gerp/views/viewsets.py index 4f7a630e..f5bbca42 100644 --- a/gvsds/apps/gerp/views/viewsets.py +++ b/gvsds/apps/gerp/views/viewsets.py @@ -8,7 +8,7 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from gvsdsdk.auth.permissions import IsAdminUser, CompanyScopedMixin, TenantScopedMixin +from gvsdsdk.auth.permissions import IsAdminUser, CompanyScopedMixin, TenantScopedMixin, UserScopedMixin from gvsdsdk.model_utils import UUIDObj from . import ELTAuthenticatedAPIView from ..models import ( @@ -37,7 +37,7 @@ from ..serializers import ( ) -class TenantViewSet(viewsets.ModelViewSet): +class TenantViewSet(TenantScopedMixin, viewsets.ModelViewSet): queryset = Tenant.objects.all() serializer_class = TenantSerializer permission_classes = [IsAuthenticated] @@ -50,7 +50,8 @@ class TenantViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Tenant.objects.all() + # 使用 get_queryset() 自动应用租户隔离(管理员看全部,普通用户只看当前租户) + qs = self.get_queryset() if keyword: qs = qs.filter(TenantName__icontains=keyword) serializer = self.get_serializer(qs[:20], many=True) @@ -76,7 +77,8 @@ class CompanyViewSet(TenantScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Company.objects.all() + # 使用 get_queryset() 自动应用租户隔离(管理员看全部,普通用户只看当前租户) + qs = self.get_queryset() if keyword: from django.db.models import Q qs = qs.filter( @@ -101,7 +103,8 @@ class DepartmentViewSet(CompanyScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Department.objects.all() + # 使用 get_queryset() 自动应用公司隔离(管理员看全部,普通用户只看当前公司) + qs = self.get_queryset() if keyword: qs = qs.filter(DeptName__icontains=keyword) serializer = self.get_serializer(qs[:20], many=True) @@ -119,6 +122,18 @@ class DepartmentViewSet(CompanyScopedMixin, viewsets.ModelViewSet): user_bytes = UUIDObj(user_uuid).bytes if isinstance(user_uuid, str) else user_uuid except (ValueError, AttributeError): return Response({'code': 1, 'msg': '无效的UUID'}, status=400) + # 归属校验:非 admin:super 校验部门归属当前公司(防跨公司设置负责人) + from gvsdsdk.auth.admin_service import IsSuperAdmin + if not IsSuperAdmin(request): + current_company = getattr(request, 'current_company_uuid', None) + if hasattr(current_company, 'pk'): + current_company = current_company.pk + elif hasattr(current_company, 'bytes'): + current_company = current_company.bytes + if current_company: + dept = Department.objects.filter(DeptUUID=dept_bytes).first() + if dept and getattr(dept, 'CompanyUUID_id', None) != current_company: + return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403) smtm, created = DepartmentSMTM.objects.update_or_create( DepartmentUUID=dept_bytes, UserUUID=user_bytes, @@ -140,7 +155,7 @@ class RoleViewSet(TenantScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Role.objects.all() + qs = self.get_queryset() if keyword: qs = qs.filter(RoleName__icontains=keyword) serializer = self.get_serializer(qs[:20], many=True) @@ -154,7 +169,7 @@ class WorkgroupViewSet(CompanyScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Workgroup.objects.all() + qs = self.get_queryset() if keyword: qs = qs.filter(WorkgroupName__icontains=keyword) data = [] @@ -182,6 +197,19 @@ class UserDeptViewSet(viewsets.ModelViewSet): ser = UserDeptSerializer_Create(data=request.data) ser.is_valid(raise_exception=True) data = ser.validated_data + # 归属校验:非 admin:super 校验 DeptUUID 归属当前公司(防跨公司/跨租户操作) + from gvsdsdk.auth.admin_service import IsSuperAdmin + if not IsSuperAdmin(request): + current_company = getattr(request, 'current_company_uuid', None) + if hasattr(current_company, 'pk'): + current_company = current_company.pk + elif hasattr(current_company, 'bytes'): + current_company = current_company.bytes + if current_company: + from gvsds.apps.gerp.models import Department + dept = Department.objects.filter(DeptUUID=data['DeptUUID']).first() + if dept and getattr(dept, 'CompanyUUID_id', None) != current_company: + return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403) existing = UserDept.objects.filter( UserUUID=data['UserUUID'], DeptUUID=data['DeptUUID'], @@ -203,6 +231,19 @@ class UserDeptViewSet(viewsets.ModelViewSet): ser = UserDeptRemoveSerializer(data=request.data) ser.is_valid(raise_exception=True) data = ser.validated_data + # 归属校验:非 admin:super 校验 DeptUUID 归属当前公司(防跨公司/跨租户操作) + from gvsdsdk.auth.admin_service import IsSuperAdmin + if not IsSuperAdmin(request): + current_company = getattr(request, 'current_company_uuid', None) + if hasattr(current_company, 'pk'): + current_company = current_company.pk + elif hasattr(current_company, 'bytes'): + current_company = current_company.bytes + if current_company and data.get('DeptUUID'): + from gvsds.apps.gerp.models import Department + dept = Department.objects.filter(DeptUUID=data['DeptUUID']).first() + if dept and getattr(dept, 'CompanyUUID_id', None) != current_company: + return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403) deleted, _ = UserDept.objects.filter( UserUUID=data['UserUUID'], DeptUUID=data['DeptUUID'], @@ -222,7 +263,34 @@ class RolePermissionViewSet(CompanyScopedMixin, viewsets.ModelViewSet): ser = RolePermissionAssignSerializer(data=request.data) ser.is_valid(raise_exception=True) data = ser.validated_data + # CompanyUUID 默认用当前公司上下文(序列化器未要求该字段) company_uuid = data.get('CompanyUUID') + if not company_uuid: + current_company = getattr(request, 'current_company_uuid', None) + if current_company is not None: + if hasattr(current_company, 'pk'): + company_uuid = current_company.pk + elif hasattr(current_company, 'bytes'): + company_uuid = current_company.bytes + # 归属校验:非 admin:super 校验 RoleUUID/PermUUID 归属当前租户(防跨租户注入权限) + from gvsdsdk.auth.admin_service import IsSuperAdmin + if not IsSuperAdmin(request): + current_tenant = getattr(request, 'current_tenant_uuid', None) + if hasattr(current_tenant, 'bytes'): + current_tenant = current_tenant.bytes + elif isinstance(current_tenant, str): + try: + current_tenant = uuid.UUID(current_tenant).bytes + except ValueError: + current_tenant = None + if current_tenant: + from gvsds.apps.gerp.models import Role, Permission + role = Role.objects.filter(RoleUUID=data['RoleUUID']).first() + if role and getattr(role, 'TenantUUID_id', None) != current_tenant and getattr(role, 'TenantUUID', None) != current_tenant: + return Response({'code': 403, 'msg': '无权操作:目标角色不属于当前租户'}, status=403) + perm = Permission.objects.filter(PermUUID=data['PermUUID']).first() + if perm and getattr(perm, 'TenantUUID_id', None) != current_tenant and getattr(perm, 'TenantUUID', None) != current_tenant: + return Response({'code': 403, 'msg': '无权操作:目标权限不属于当前租户'}, status=403) existing = RolePermission.objects.filter( RoleUUID=data['RoleUUID'], PermUUID=data['PermUUID'], @@ -265,7 +333,7 @@ class PermissionViewSet(TenantScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Permission.objects.all() + qs = self.get_queryset() if keyword: from django.db.models import Q qs = qs.filter(Q(PermName__icontains=keyword) | Q(PermCode__icontains=keyword)) @@ -279,6 +347,21 @@ class PermissionViewSet(TenantScopedMixin, viewsets.ModelViewSet): perm_code_raw = serializer.validated_data['PermCode'].strip() tenant_uuid = serializer.validated_data['TenantUUID'] + # 租户隔离:非 admin:super 强制使用当前租户 UUID(防跨租户创建权限) + # admin:super 可跨租户创建;admin:tenant 和普通用户只能创建本租户权限 + from gvsdsdk.auth.admin_service import IsSuperAdmin + if not IsSuperAdmin(request): + current_tenant = getattr(request, 'current_tenant_uuid', None) + if current_tenant is not None: + if hasattr(current_tenant, 'bytes'): + tenant_uuid = current_tenant.bytes + elif isinstance(current_tenant, str): + try: + tenant_uuid = uuid.UUID(current_tenant).bytes + except ValueError: + pass + elif isinstance(current_tenant, bytes): + tenant_uuid = current_tenant perm_name = serializer.validated_data.get('PermName', '') perm_category = serializer.validated_data.get('PermCategory', '') perm_status = serializer.validated_data.get('PermStatus', 1) @@ -340,7 +423,7 @@ class FileAssetViewSet(CompanyScopedMixin, viewsets.ModelViewSet): asset_type = request.query_params.get('type', '') keyword = request.query_params.get('keyword', '') - qs = FileAsset.objects.all() + qs = self.get_queryset() if keyword: qs = qs.filter(FileName__icontains=keyword) @@ -551,6 +634,15 @@ class ItemReferenceViewSet(viewsets.ModelViewSet): item = ItemReference.objects.get(ItemUUID=item_bytes) except (ItemReference.DoesNotExist, ValueError): return Response({'code': 1, 'msg': '物品不存在'}, status=404) + # IDOR 防护:校验物品归属当前用户(管理员放行) + from gvsdsdk.auth.admin_service import IsAdmin + if not IsAdmin(request): + _pub = item.ItemPublisherUserUUID + _usr = request.user.UserUUID + _pub_b = _pub.bytes if hasattr(_pub, 'bytes') else _pub + _usr_b = _usr.bytes if hasattr(_usr, 'bytes') else _usr + if _pub_b != _usr_b: + return Response({'code': 403, 'msg': '无权操作他人物品'}, status=403) for field, val in data.items(): setattr(item, field, val) item.save() @@ -564,10 +656,19 @@ class ItemReferenceViewSet(viewsets.ModelViewSet): try: item_bytes = UUIDObj(item_uuid).bytes item = ItemReference.objects.get(ItemUUID=item_bytes) - item.delete() - return Response({'code': 0, 'msg': '删除成功'}) except (ItemReference.DoesNotExist, ValueError): return Response({'code': 1, 'msg': '物品不存在'}, status=404) + # IDOR 防护:校验物品归属当前用户(管理员放行) + from gvsdsdk.auth.admin_service import IsAdmin + if not IsAdmin(request): + _pub = item.ItemPublisherUserUUID + _usr = request.user.UserUUID + _pub_b = _pub.bytes if hasattr(_pub, 'bytes') else _pub + _usr_b = _usr.bytes if hasattr(_usr, 'bytes') else _usr + if _pub_b != _usr_b: + return Response({'code': 403, 'msg': '无权操作他人物品'}, status=403) + item.delete() + return Response({'code': 0, 'msg': '删除成功'}) class BackpackViewSet(CompanyScopedMixin, viewsets.ModelViewSet): @@ -605,6 +706,15 @@ class BackpackViewSet(CompanyScopedMixin, viewsets.ModelViewSet): bp = Backpack.objects.get(BackpackItemUUID=bp_bytes) except (Backpack.DoesNotExist, ValueError): return Response({'code': 1, 'msg': '背包物品不存在'}, status=404) + # IDOR 防护:校验物品归属当前用户(管理员放行) + # 统一转 bytes 比较,避免 UUIDObj 与 bytes 类型不一致导致误判 + from gvsdsdk.auth.admin_service import IsAdmin + if not IsAdmin(request): + _bp_user = bp.UserUUID_id + _req_user = request.user.UserUUID + _req_user_b = _req_user.bytes if hasattr(_req_user, 'bytes') else _req_user + if _bp_user != _req_user_b: + return Response({'code': 403, 'msg': '无权操作他人背包物品'}, status=403) for field, val in data.items(): setattr(bp, field, val) bp.save() @@ -622,6 +732,15 @@ class BackpackViewSet(CompanyScopedMixin, viewsets.ModelViewSet): bp = Backpack.objects.get(BackpackItemUUID=bp_bytes) except (Backpack.DoesNotExist, ValueError): return Response({'code': 1, 'msg': '背包物品不存在'}, status=404) + # IDOR 防护:校验物品归属当前用户(管理员放行) + # 统一转 bytes 比较,避免 UUIDObj 与 bytes 类型不一致导致误判 + from gvsdsdk.auth.admin_service import IsAdmin + if not IsAdmin(request): + _bp_user = bp.UserUUID_id + _req_user = request.user.UserUUID + _req_user_b = _req_user.bytes if hasattr(_req_user, 'bytes') else _req_user + if _bp_user != _req_user_b: + return Response({'code': 403, 'msg': '无权操作他人背包物品'}, status=403) if bp.ItemLength <= discard_len: bp.delete() return Response({'code': 0, 'msg': '物品已丢弃'}) @@ -654,7 +773,7 @@ class CashReceiptsPaymentsLedgerViewSet(CompanyScopedMixin, viewsets.ModelViewSe ser = FinanceShowSerializer(data=request.query_params or request.data) ser.IsValid(raise_exception=True) data = ser.ValidatedData - qs = CashReceiptsPaymentsLedger.objects.all() + qs = self.get_queryset() if 'StartDate' in data: qs = qs.filter(CashBookTransactionTime__gte=data['StartDate']) if 'EndDate' in data: @@ -669,16 +788,18 @@ class CashReceiptsPaymentsLedgerViewSet(CompanyScopedMixin, viewsets.ModelViewSe return Response({'code': 0, 'data': serializer.data}) -class AssetViewSet(CompanyScopedMixin, viewsets.ModelViewSet): +class AssetViewSet(UserScopedMixin, viewsets.ModelViewSet): queryset = Asset.objects.all() serializer_class = AssetSerializer permission_classes = [IsAuthenticated] + user_field = 'AssetUserUUID' -class LiabilityViewSet(CompanyScopedMixin, viewsets.ModelViewSet): +class LiabilityViewSet(UserScopedMixin, viewsets.ModelViewSet): queryset = Liability.objects.all() serializer_class = LiabilitySerializer permission_classes = [IsAuthenticated] + user_field = 'LiabilityUserUUID' class PositionTemplateViewSet(TenantScopedMixin, viewsets.ModelViewSet): @@ -689,7 +810,7 @@ class PositionTemplateViewSet(TenantScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = PositionTemplate.objects.all() + qs = self.get_queryset() if keyword: qs = qs.filter(TemplateName__icontains=keyword) serializer = self.get_serializer(qs[:20], many=True) @@ -715,7 +836,8 @@ class PositionViewSet(TenantScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): keyword = request.query_params.get('keyword', '') - qs = Position.objects.all() + # 使用 get_queryset() 自动应用租户隔离(管理员看全部,普通用户只看当前租户) + qs = self.get_queryset() if keyword: qs = qs.filter(PositionName__icontains=keyword) data = list(qs[:20].values('PositionUUID', 'PositionName')) @@ -734,9 +856,9 @@ class ShopViewSet(CompanyScopedMixin, viewsets.ModelViewSet): @action(detail=False, methods=['get']) def search(self, request): - from gvsdsdk.models import Shop as ShopModel keyword = request.query_params.get('keyword', '') - qs = ShopModel.objects.all() + # 使用 get_queryset() 自动应用公司隔离(管理员看全部,普通用户只看当前公司) + qs = self.get_queryset() if keyword: qs = qs.filter(ShopName__icontains=keyword) data = list(qs[:20].values('ShopUUID', 'ShopName')) diff --git a/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc b/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc index 6e9db8b9..889ffb35 100644 Binary files a/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc and b/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc differ diff --git a/gvsds/apps/ugm/views.py b/gvsds/apps/ugm/views.py index 895c4984..3504c6a5 100644 --- a/gvsds/apps/ugm/views.py +++ b/gvsds/apps/ugm/views.py @@ -90,11 +90,12 @@ def _ClearJwtCookies(response): def _SetCurrentRoleCookie(response, user): """查询用户首个角色并设置 CurrentRoleUUID cookie""" from gvsds.apps.gerp.models import UserRole as _UserRole - first_role = _UserRole.objects.filter(UserUUID=user.UserUUID).select_related('RoleUUID').first() + # UserRole.RoleUUID 是 UUIDField,不能 select_related + first_role = _UserRole.objects.filter(UserUUID=user.UserUUID).first() if first_role and first_role.RoleUUID: max_age = int(settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'].total_seconds()) response.set_cookie( - 'CurrentRoleUUID', UUIDObj(first_role.RoleUUID.RoleUUID).str, + 'CurrentRoleUUID', UUIDObj(first_role.RoleUUID).str, domain=settings.SESSION_COOKIE_DOMAIN, max_age=max_age, secure=True, httponly=False, samesite='None', ) diff --git a/gvsds/auth.py b/gvsds/auth.py index 2d1eacbc..91f4232f 100644 --- a/gvsds/auth.py +++ b/gvsds/auth.py @@ -8,23 +8,94 @@ SimpleJWT 的 JWTAuthentication 在 __init__ 中调用 django.contrib.auth.get_u 同时支持从 Authorization 请求头和 HttpOnly Cookie 读取 JWT, 以兼容前端基于 Cookie 的认证方式(credentials: 'include')。 + +额外职责:JWT 认证成功后立即解析当前角色上下文(current_role / current_company_uuid / +current_tenant_uuid),填充到 request 对象。这解决了 Django 中间件在 DRF 认证之前运行、 +导致中间件无法获取已认证用户的问题。 """ +import logging +from django.core.exceptions import ObjectDoesNotExist from rest_framework_simplejwt.authentication import JWTAuthentication from gvsds.apps.core.models import User +_logger = logging.getLogger(__name__) + #: 存储 Access Token 的 Cookie 名称 JWT_ACCESS_COOKIE = 'AccessToken' #: 存储 Refresh Token 的 Cookie 名称 JWT_REFRESH_COOKIE = 'RefreshToken' +def ResolveRoleContext(request, user): + """解析当前角色上下文,填充 request.current_role / current_company_uuid / current_tenant_uuid + + 从 CurrentRoleUUID Cookie 读取角色 UUID,查询 UserRole + Role, + 设置 request.current_role / current_company_uuid / current_tenant_uuid。 + + Args: + request: HTTP 请求对象(会被原地修改) + user: 已认证的 User 实例 + """ + # 确保默认值(防止重复调用时残留旧值) + request.current_role = None + request.current_company_uuid = None + request.current_tenant_uuid = None + + if not user or not getattr(user, 'is_authenticated', False): + if hasattr(user, 'IsAuthenticated'): + if not user.IsAuthenticated: + return + else: + return + + from gvsdsdk.models import UserRole, Role + from gvsdsdk.model_utils import UUIDObj + + role_uuid_str = request.COOKIES.get('CurrentRoleUUID', '') + if not role_uuid_str: + _logger.warning('[ResolveRoleContext] 已认证用户 %s 无 CurrentRoleUUID cookie', user) + return + + try: + role_uuid_bytes = UUIDObj(role_uuid_str).bytes + user_role = UserRole.objects.filter( + UserUUID=user.UserUUID, + RoleUUID=role_uuid_bytes + ).first() + if not user_role: + _logger.warning('[ResolveRoleContext] cookie=%s 但 UserRole 未找到 (user=%s)', + role_uuid_str[:36], user) + return + # 单独查询 Role 实例(Role.TenantUUID 提供租户信息) + role = Role.objects.filter(RoleUUID=role_uuid_bytes).first() + if role: + request.current_role = role + # 从 Role 实例获取租户 UUID(UUIDObj,FormService 会转 bytes) + request.current_tenant_uuid = role.TenantUUID + # UserRole.CompanyUUID 是 UUIDField,直接赋值 + if user_role.CompanyUUID: + request.current_company_uuid = user_role.CompanyUUID + _logger.info('[ResolveRoleContext] cookie=%s, role=%s, tenant=%s, company=%s', + role_uuid_str[:36], + getattr(role, 'RoleName', None) if role else None, + request.current_tenant_uuid, + request.current_company_uuid) + except (ValueError, KeyError, ObjectDoesNotExist) as e: + _logger.warning('[ResolveRoleContext] cookie=%s 解析失败: %s', role_uuid_str[:36], e) + except Exception: + _logger.exception('[ResolveRoleContext] 解析当前角色失败') + + class CustomJWTAuthentication(JWTAuthentication): """JWT 认证——使用项目自定义 User 模型(core.User) 优先从 Authorization 请求头读取 token; 若请求头无 token,则回退到 HttpOnly Cookie(AccessToken)。 + + 认证成功后立即解析当前角色上下文(ResolveRoleContext), + 确保 request.current_role / current_company_uuid / current_tenant_uuid 可用。 """ def __init__(self, *args, **kwargs): @@ -47,3 +118,14 @@ class CustomJWTAuthentication(JWTAuthentication): def get_auth_header_types(self): from rest_framework_simplejwt.settings import api_settings return api_settings.AUTH_HEADER_TYPES + + def authenticate(self, request): + """JWT 认证成功后立即解析角色上下文""" + result = super().authenticate(request) + if result is None: + return None + user, validated_token = result + # 认证成功后立即解析角色上下文(DRF 会在 authenticate 返回后设置 request.user, + # 但此时我们已有 user 实例,可直接传入解析) + ResolveRoleContext(request, user) + return result diff --git a/gvsdsdk/__pycache__/model_utils.cpython-312.pyc b/gvsdsdk/__pycache__/model_utils.cpython-312.pyc index 88a54a79..ff05aff6 100644 Binary files a/gvsdsdk/__pycache__/model_utils.cpython-312.pyc and b/gvsdsdk/__pycache__/model_utils.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__init__.py b/gvsdsdk/auth/__init__.py index d7214e5f..69db92b7 100644 --- a/gvsdsdk/auth/__init__.py +++ b/gvsdsdk/auth/__init__.py @@ -4,8 +4,9 @@ __all__ = [ 'GetDefaultAvatar', 'HashPassword', 'VerifyPassword', 'SymmetricEncrypt', 'SymmetricDecrypt', 'FileSHA1', 'FileSHA256', 'BytesSHA1', 'BytesSHA256', 'CurrentRoleMiddleware', - 'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'PermissionVerifier', - 'IsAdmin', + 'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier', + 'IsAdminScopeDisabled', 'IsTenantScopeDisabled', + 'IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin', 'RoleManager', 'ShopManager', 'PositionManager', ] @@ -24,15 +25,16 @@ def __getattr__(name): from gvsdsdk.auth.middleware import CurrentRoleMiddleware globals()['CurrentRoleMiddleware'] = CurrentRoleMiddleware return CurrentRoleMiddleware - if name in ('IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'PermissionVerifier'): + if name in ('IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier', 'IsAdminScopeDisabled', 'IsTenantScopeDisabled'): from gvsdsdk.auth import permissions as _m val = getattr(_m, name) globals()[name] = val return val - if name == 'IsAdmin': - from gvsdsdk.auth.admin_service import IsAdmin - globals()['IsAdmin'] = IsAdmin - return IsAdmin + if name in ('IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin'): + from gvsdsdk.auth import admin_service as _m + val = getattr(_m, name) + globals()[name] = val + return val if name == 'RoleManager': from gvsdsdk.auth.managers import RoleManager globals()['RoleManager'] = RoleManager diff --git a/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc index 8c648e71..113e3b6a 100644 Binary files a/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/admin_service.cpython-312.pyc b/gvsdsdk/auth/__pycache__/admin_service.cpython-312.pyc index 541128a2..81a9d68a 100644 Binary files a/gvsdsdk/auth/__pycache__/admin_service.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/admin_service.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc b/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc index c8ee519c..a370565b 100644 Binary files a/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/middleware.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc b/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc index 52976886..b1e544f0 100644 Binary files a/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/permissions.cpython-312.pyc differ diff --git a/gvsdsdk/auth/admin_service.py b/gvsdsdk/auth/admin_service.py index 6b7d261c..5e1d3251 100644 --- a/gvsdsdk/auth/admin_service.py +++ b/gvsdsdk/auth/admin_service.py @@ -1,24 +1,31 @@ """gvsdsdk.auth.admin_service — 管理员身份统一判定服务 -提供单一的 ``IsAdmin`` 函数,作为所有管理员特权判断的统一入口。 -消除 gerp 各视图中重复实现的 ``_is_admin`` / ``_IsAdminUser`` / ``IsAdmin`` 等函数, -统一 ``admin:super`` 权限查询逻辑。 +提供管理员身份判定的统一入口,基于两个层级的管理员权限码: -判定顺序: + ``admin:super`` —— 跨租户管理员(如 GERP 测试员),关闭租户隔离 + 公司隔离 + ``admin:tenant`` —— 租户内管理员(如 集团超级管理员),仅关闭公司隔离,保留租户隔离 + +判定顺序(适用于所有判定函数): 1. 用户未认证 → False - 2. IsSuperuser 或 IsStaff → True(系统级管理员) - 3. 当前角色拥有 ``admin:super`` 权限 → True(租户级管理员) + 2. 有当前角色 → 仅依据该角色的权限码判定(角色超级管理员模式) + 超级用户切换到普通角色时也应用隔离,不再因 IsSuperuser/IsStaff 直接放行 + 3. 无当前角色 → IsSuperuser 或 IsStaff 作为系统维护回退(仅授予 admin:tenant 级别, + 不授予 admin:super,防止超级用户在未切换角色时获得跨租户越权能力) 4. 否则 → False +提供的函数: + ``IsAdmin`` —— 是否为管理员(admin:super 或 admin:tenant),用于权限校验 + ``IsSuperAdmin`` —— 是否为跨租户管理员(仅 admin:super),用于租户隔离判定 + ``IsTenantAdmin``—— 是否为租户内管理员(仅 admin:tenant),用于租户内管理员判定 + 用法: - from gvsdsdk.auth.admin_service import IsAdmin + from gvsdsdk.auth.admin_service import IsAdmin, IsSuperAdmin if IsAdmin(request): - ... + ... # 权限校验放行 - # 或无 request 场景(如 service 层) - if IsAdmin(user=user, role_uuid=role_uuid_bytes): - ... + if IsSuperAdmin(request): + ... # 关闭租户隔离 """ from __future__ import annotations @@ -39,14 +46,62 @@ def _IsAuthenticated(user) -> bool: return False -def IsAdmin(request=None, user=None, role_uuid=None) -> bool: - """判断用户是否为管理员(系统级或租户级) +def _HasPerm(request=None, user=None, role_uuid=None, perm_code=None) -> bool: + """判断当前角色是否拥有指定权限码 + 内部函数,供 IsAdmin / IsSuperAdmin / IsTenantAdmin 复用。 判定顺序: 1. 用户未认证 → False - 2. ``IsSuperuser`` 或 ``IsStaff`` → True - 3. 当前角色拥有 ``admin:super`` 权限 → True + 2. 有当前角色 → 仅依据该角色的指定权限码判定(角色超级管理员模式) + 3. 无当前角色 → IsSuperuser 或 IsStaff 作为系统维护回退(仅授予 admin:tenant 级别, + perm_code='admin:super' 时直接返回 False,防止跨租户越权) 4. 否则 → False + """ + if perm_code is None: + return False + + # 1. 解析 user + if request is not None and user is None: + user = getattr(request, 'user', None) + if not _IsAuthenticated(user): + return False + + # 2. 解析 role_uuid(优先从参数,其次从 request.current_role) + if role_uuid is None and request is not None: + current_role = getattr(request, 'current_role', None) + if current_role: + role_uuid = getattr(current_role, 'RoleUUID', None) + + # 3. 有当前角色时,仅依据该角色的指定权限码判定(角色超级管理员模式) + if role_uuid: + try: + from gvsdsdk.models import RolePermission, Permission + perm_uuids = Permission.objects.filter( + PermCode=perm_code, + PermStatus=1, + ).values_list('PermUUID', flat=True) + return RolePermission.objects.filter( + RoleUUID=role_uuid, + PermUUID__in=perm_uuids, + ).exists() + except Exception: + _logger.warning('查询权限 %s 失败', perm_code, exc_info=True) + return False + + # 4. 无当前角色时,IsSuperuser/IsStaff 作为系统维护回退(无角色无法做权限判定) + # 安全策略:无角色时仅授予 admin:tenant 级别权限(不授予 admin:super), + # 防止超级用户在未切换角色时获得跨租户越权能力。 + # superuser 无角色 → IsAdmin=True / IsTenantAdmin=True / IsSuperAdmin=False + if perm_code == 'admin:super': + return False + return bool(getattr(user, 'IsSuperuser', False) or getattr(user, 'IsStaff', False)) + + +def IsAdmin(request=None, user=None, role_uuid=None) -> bool: + """判断用户是否为管理员(admin:super 或 admin:tenant) + + 用于权限校验:两种层级的管理员都应放行。 + 跨租户管理员(admin:super)和租户内管理员(admin:tenant)都是管理员。 参数: request: 请求对象。优先从 ``request.user`` 取用户、``request.current_role`` 取角色。 @@ -57,38 +112,22 @@ def IsAdmin(request=None, user=None, role_uuid=None) -> bool: 返回: bool """ - # 1. 解析 user - if request is not None and user is None: - user = getattr(request, 'user', None) - if not _IsAuthenticated(user): - return False - - # 2. 系统级管理员(超级用户/员工) - if getattr(user, 'IsSuperuser', False) or getattr(user, 'IsStaff', False): + if _HasPerm(request, user, role_uuid, 'admin:super'): return True + return _HasPerm(request, user, role_uuid, 'admin:tenant') - # 3. 解析 role_uuid - if role_uuid is None and request is not None: - current_role = getattr(request, 'current_role', None) - if current_role: - role_uuid = getattr(current_role, 'RoleUUID', None) - if not role_uuid: - return False - # 4. 租户级管理员(当前角色拥有 admin:super 权限) - # 注意:gvsdsdk.models.RolePermission 的 RoleUUID/PermUUID 都是 UUIDField(非 ForeignKey), - # 不能用 RoleUUID_id= 或 PermUUID__PermCode 跨表查询。 - # 改用 subquery + 直接 UUID 匹配。 - try: - from gvsdsdk.models import RolePermission, Permission - admin_super_uuids = Permission.objects.filter( - PermCode='admin:super', - PermStatus=1, - ).values_list('PermUUID', flat=True) - return RolePermission.objects.filter( - RoleUUID=role_uuid, - PermUUID__in=admin_super_uuids, - ).exists() - except Exception: - _logger.warning('IsAdmin 查询 admin:super 权限失败', exc_info=True) - return False +def IsSuperAdmin(request=None, user=None, role_uuid=None) -> bool: + """判断用户是否为跨租户管理员(仅 admin:super) + + 用于隔离控制:仅 admin:super 关闭租户隔离,可跨租户查看数据。 + """ + return _HasPerm(request, user, role_uuid, 'admin:super') + + +def IsTenantAdmin(request=None, user=None, role_uuid=None) -> bool: + """判断用户是否为租户内管理员(仅 admin:tenant) + + 用于租户内管理员判定:仅关闭公司隔离,保留租户隔离。 + """ + return _HasPerm(request, user, role_uuid, 'admin:tenant') diff --git a/gvsdsdk/auth/middleware.py b/gvsdsdk/auth/middleware.py index e0fa04fd..6ef90355 100644 --- a/gvsdsdk/auth/middleware.py +++ b/gvsdsdk/auth/middleware.py @@ -46,9 +46,18 @@ class CurrentRoleMiddleware: # UserRole.CompanyUUID 是 UUIDField,直接赋值 UUID bytes if user_role.CompanyUUID: request.current_company_uuid = user_role.CompanyUUID - except (ValueError, KeyError, ObjectDoesNotExist): - pass + logger.info('[CurrentRoleMiddleware] cookie=%s, role=%s, tenant=%s, company=%s', + role_uuid_str[:36], + getattr(role, 'RoleName', None) if role else None, + request.current_tenant_uuid, + request.current_company_uuid) + else: + logger.warning('[CurrentRoleMiddleware] cookie=%s 但 UserRole 未找到 (user=%s)', + role_uuid_str[:36], request.user) + except (ValueError, KeyError, ObjectDoesNotExist) as e: + logger.warning('[CurrentRoleMiddleware] cookie=%s 解析失败: %s', role_uuid_str[:36], e) except Exception: logger.exception('CurrentRoleMiddleware 解析当前角色失败') - + else: + logger.warning('[CurrentRoleMiddleware] 已认证用户 %s 无 CurrentRoleUUID cookie', request.user) return self.get_response(request) diff --git a/gvsdsdk/auth/permissions.py b/gvsdsdk/auth/permissions.py index 7546abd9..25daf9d0 100644 --- a/gvsdsdk/auth/permissions.py +++ b/gvsdsdk/auth/permissions.py @@ -8,31 +8,40 @@ _logger = _logging.getLogger(__name__) def IsAdminScopeDisabled(request): - """判断是否为管理员特权模式(X-Admin-Verify 头或 admin_verify=1 且用户是管理员),此时关闭隔离 + """判断是否为管理员特权模式(当前角色拥有 admin:super 或 admin:tenant 权限),此时关闭公司隔离 - 安全:优先从请求头 ``X-Admin-Verify`` 读取(避免 URL 泄漏到访问日志/浏览器历史/Referer), - 兼容旧版 query 参数 ``admin_verify=1`` 以保证前端渐进迁移。 - - 管理员判定委托给 ``gvsdsdk.auth.admin_service.IsAdmin``,统一 ``admin:super`` 查询逻辑。 + 与 FormService 行为一致:管理员自动关闭公司隔离,无需前端传 admin_verify=1。 + 管理员判定委托给 ``gvsdsdk.auth.admin_service.IsAdmin``,统一权限查询逻辑。 """ if not request: return False - # 优先从请求头读取(更安全),兼容 query 参数 - admin_verify = ( - request.headers.get('X-Admin-Verify') == '1' - or ( - hasattr(request, 'query_params') - and request.query_params.get('admin_verify') == '1' - ) - ) - if not admin_verify: - return False from gvsdsdk.auth.admin_service import IsAdmin return IsAdmin(request) +def IsTenantScopeDisabled(request): + """判断是否为跨租户管理员特权模式(当前角色拥有 admin:super 权限),此时关闭租户隔离 + + 仅 admin:super(跨租户管理员)可关闭租户隔离,admin:tenant(租户内管理员)保留租户隔离。 + 管理员判定委托给 ``gvsdsdk.auth.admin_service.IsSuperAdmin``。 + """ + if not request: + return False + from gvsdsdk.auth.admin_service import IsSuperAdmin + return IsSuperAdmin(request) + + class IsAdminUser(BasePermission): - """仅允许管理员(IsStaff 或 IsSuperuser)访问""" + """仅允许管理员(当前角色拥有 admin:super 或 admin:tenant 权限)访问 + + 两级管理员体系: + - admin:super:跨租户管理员(GERP 测试员),关闭租户隔离 + 公司隔离 + - admin:tenant:租户内管理员(集团超级管理员),仅关闭公司隔离,保留租户隔离 + + 依据当前角色的权限判定,超级用户切换到普通角色时不再自动放行, + 需切换到拥有 admin:super 或 admin:tenant 权限的角色。 + 无当前角色时回退到 IsSuperuser/IsStaff(系统维护场景)。 + """ def has_permission(self, request, view): user = request.user @@ -40,7 +49,8 @@ class IsAdminUser(BasePermission): return False if not isinstance(user, _GetUserModel()): return False - return user.IsStaff or user.IsSuperuser + from gvsdsdk.auth.admin_service import IsAdmin + return IsAdmin(request) class IsCompanyMember(BasePermission): @@ -99,13 +109,17 @@ class TenantScopedMixin: """ViewSet Mixin:自动按当前角色所属租户过滤 queryset 用于纯租户级表(如 Tenant/Role/CompanyInfo),这些表无 CompanyUUID 字段, - 只能按 TenantUUID 过滤。当 URL 带 admin_verify=1 且用户为管理员时关闭隔离。 + 只能按 TenantUUID 过滤。仅跨租户管理员(admin:super)关闭租户隔离, + 租户内管理员(admin:tenant)保留租户隔离。 """ tenant_field = 'TenantUUID' # 可在 ViewSet 中覆盖 def _is_admin_scope_disabled(self): - """判断是否为管理员特权模式(委托给模块级 IsAdminScopeDisabled)""" - return IsAdminScopeDisabled(getattr(self, 'request', None)) + """判断是否为跨租户管理员特权模式(委托给模块级 IsTenantScopeDisabled) + + 仅 admin:super 关闭租户隔离,admin:tenant 保留租户隔离。 + """ + return IsTenantScopeDisabled(getattr(self, 'request', None)) def get_queryset(self): qs = super().get_queryset() @@ -124,6 +138,43 @@ class TenantScopedMixin: serializer.save() +class UserScopedMixin: + """ViewSet Mixin:自动按当前用户过滤 queryset(用户级数据隔离) + + 用于无 CompanyUUID/TenantUUID 字段的用户级表(如 Backpack/Message/WorkOrder/Asset/Liability), + 普通用户只能看自己的数据,管理员(admin:super 或 admin:tenant)看全部。 + + 使用方式: + class BackpackViewSet(UserScopedMixin, viewsets.ModelViewSet): + user_field = 'UserUUID' # FK 字段名(自动追加 _id)或 UUIDField 名 + """ + user_field = 'UserUUID' # 可在 ViewSet 中覆盖 + + def _is_admin_scope_disabled(self): + """判断是否为管理员特权模式(委托给模块级 IsAdminScopeDisabled)""" + return IsAdminScopeDisabled(getattr(self, 'request', None)) + + def get_queryset(self): + qs = super().get_queryset() + if self._is_admin_scope_disabled(): + return qs + user = getattr(self.request, 'user', None) if hasattr(self, 'request') else None + if not user or not getattr(user, 'is_authenticated', False): + return qs.none() + user_uuid = getattr(user, 'UserUUID', None) + if user_uuid is None: + return qs.none() + # 统一转 bytes(UUIDObj → bytes) + user_uuid = user_uuid.bytes if hasattr(user_uuid, 'bytes') else user_uuid + # 判断字段类型:ForeignKey 用 _id 后缀,UUIDField 直接用字段名 + fk_attr = self.user_field + '_id' + if hasattr(qs.model, fk_attr): + qs = qs.filter(**{fk_attr: user_uuid}) + elif hasattr(qs.model, self.user_field): + qs = qs.filter(**{self.user_field: user_uuid}) + return qs + + _PERM_CACHE_KEY = '_gvsdsdk_perm_verifier_cache' _ROLE_INACTIVE = object() # 哨兵:角色未启用,恒拒绝 @@ -133,7 +184,7 @@ def PermissionVerifier(permission, attributes, user=None, request=None): RBAC 仅检查当前角色(CurrentRoleUUID Cookie 指定的角色)的权限, 通过 RolePermission → Permission 链路查询,按当前公司过滤。 - 超级管理员/员工直接放行。 + 管理员(当前角色拥有 admin:super 权限)直接放行。 性能优化:ABAC 权限标识集合与 RBAC 权限码集合在首次调用时预取, 并挂载到 request 对象上作为请求级缓存,后续同一请求内的多次调用 @@ -146,8 +197,10 @@ def PermissionVerifier(permission, attributes, user=None, request=None): if not isinstance(user, _GetUserModel()): return False - # 超级管理员和员工直接放行 - if user.IsSuperuser or user.IsStaff: + # 管理员(当前角色拥有 admin:super 权限)直接放行 + # 采用角色超级管理员模式:超级用户切换到普通角色时也需通过 RBAC 校验 + from gvsdsdk.auth.admin_service import IsAdmin + if IsAdmin(request=request, user=user): return True current_role = getattr(request, 'current_role', None) if request else None diff --git a/gvsdsdk/model_utils.py b/gvsdsdk/model_utils.py index c7b157c1..07dc0029 100644 --- a/gvsdsdk/model_utils.py +++ b/gvsdsdk/model_utils.py @@ -63,6 +63,10 @@ class UUIDObj: def __bool__(self): return self._raw is not None + def __len__(self): + # UUID 固定 16 字节;空值返回 0 + return 16 if self._raw is not None else 0 + @lru_cache(maxsize=1) def _GetUserModel():