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():