|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
from datetime import UTC, datetime, timezone
|
|
|
from typing import Any
|
|
|
|
|
|
+from flask import current_app
|
|
|
from sqlalchemy import func, or_, select
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
@@ -39,12 +40,15 @@ from dms.services.admin_permission import (
|
|
|
can_manage_system,
|
|
|
can_view_audit_log,
|
|
|
effective_manage_role,
|
|
|
+ effective_role_snapshot,
|
|
|
ensure_org_in_managed_scope,
|
|
|
+ ensure_user_scope_in_managed_scope,
|
|
|
is_super_admin,
|
|
|
managed_scope_ids,
|
|
|
require_user_editor,
|
|
|
)
|
|
|
from dms.services.audit_service import business_audit
|
|
|
+from dms.services.authorization_service import has_any_upload_permission
|
|
|
|
|
|
|
|
|
ALLOWED_MODULES = {
|
|
|
@@ -77,6 +81,9 @@ UPDATE_FIELDS = {
|
|
|
"rowVersion",
|
|
|
}
|
|
|
RESET_PASSWORD_FIELDS = {"newPassword", "rowVersion"}
|
|
|
+# 用户级管理范围(契约1.30)为可选字段:create 未提交缺省落 SELF(哨兵0),
|
|
|
+# update 未提交保持原值,兼容不带该字段的存量调用方
|
|
|
+OPTIONAL_MANAGED_ORG_FIELDS = {"managedOrgId"}
|
|
|
|
|
|
|
|
|
def _utc_now() -> datetime:
|
|
|
@@ -226,20 +233,20 @@ def user_summary(
|
|
|
else False
|
|
|
)
|
|
|
),
|
|
|
- "managedOrgId": (
|
|
|
- None
|
|
|
+ # 上传权限(契约1.31):生效角色方案权限矩阵任一 OWN 行 can_upload=True
|
|
|
+ "permUpload": (
|
|
|
+ True
|
|
|
if super_admin
|
|
|
- else (
|
|
|
- MANAGED_ORG_SELF
|
|
|
- if manage_role is not None
|
|
|
- and manage_role.managed_org_id == MANAGED_ORG_SELF_ANCHOR
|
|
|
- else (
|
|
|
- serialize_id(manage_role.managed_org_id)
|
|
|
- if manage_role is not None
|
|
|
- else None
|
|
|
- )
|
|
|
+ else has_any_upload_permission(
|
|
|
+ manage_role.id if manage_role is not None else None
|
|
|
)
|
|
|
),
|
|
|
+ # 用户级管理范围(契约1.30):null=全局、"SELF"=跟随本人所在机构(哨兵0)、
|
|
|
+ # 机构ID=固定锚点;实际生效范围以角色锚点为准,角色为 SELF 时才回落本设置。
|
|
|
+ # 内置 admin 恒全局。
|
|
|
+ "managedOrgId": (
|
|
|
+ None if super_admin else _managed_org_value(user.managed_org_id)
|
|
|
+ ),
|
|
|
"lastLoginAt": _format_utc(user.last_login_at),
|
|
|
"lastLoginIp": user.last_login_ip,
|
|
|
"uiTheme": user.ui_theme,
|
|
|
@@ -289,19 +296,25 @@ def list_users(
|
|
|
page_request: PageRequest,
|
|
|
) -> dict[str, object]:
|
|
|
filters = [User.is_deleted.is_(False)]
|
|
|
+ # 超级管理员登录开关(安全需求7)关闭时用户列表隐藏内置 admin
|
|
|
+ if not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True):
|
|
|
+ filters.append(User.username != SUPER_ADMIN_USERNAME)
|
|
|
# 锚点管理员强制收敛到管理范围子树(全局管理员不受限),
|
|
|
# 与请求的 organization_id 过滤取交集。
|
|
|
actor = db.session.get(User, get_auth_context().user_id)
|
|
|
- if actor is not None:
|
|
|
- scope = managed_scope_ids(db.session, actor)
|
|
|
- if scope is not None:
|
|
|
- filters.append(User.organization_id.in_(scope))
|
|
|
+ scope = managed_scope_ids(db.session, actor) if actor is not None else None
|
|
|
+ if scope is not None:
|
|
|
+ filters.append(User.organization_id.in_(scope))
|
|
|
if organization_id is not None:
|
|
|
organization_ids = (
|
|
|
_descendant_ids(organization_id)
|
|
|
if include_descendants
|
|
|
else {organization_id}
|
|
|
)
|
|
|
+ # 显式请求的机构不在管理范围内:明确报无权限,
|
|
|
+ # 不再静默交集返回空页(避免前端误显示"该机构无用户")
|
|
|
+ if scope is not None and not (scope & organization_ids):
|
|
|
+ raise ForbiddenError("无权限查看该机构的用户")
|
|
|
filters.append(User.organization_id.in_(organization_ids))
|
|
|
if keyword:
|
|
|
pattern = f"%{keyword.strip()}%"
|
|
|
@@ -317,7 +330,7 @@ def list_users(
|
|
|
users = db.session.scalars(
|
|
|
select(User)
|
|
|
.where(*filters)
|
|
|
- .order_by(User.real_name.asc(), User.id.asc())
|
|
|
+ .order_by(User.sort_no.asc(), User.id.asc())
|
|
|
.offset(page_request.offset)
|
|
|
.limit(page_request.page_size)
|
|
|
).all()
|
|
|
@@ -389,10 +402,16 @@ def _row_version(value: Any) -> int:
|
|
|
return value
|
|
|
|
|
|
|
|
|
-def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
|
|
|
- if not isinstance(payload, dict) or set(payload) != expected:
|
|
|
+def _exact_payload(
|
|
|
+ payload: Any,
|
|
|
+ expected: set[str],
|
|
|
+ *,
|
|
|
+ optional: set[str] | None = None,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ allowed = expected | (optional or set())
|
|
|
+ if not isinstance(payload, dict) or not expected <= set(payload) <= allowed:
|
|
|
raise InvalidArgumentError(
|
|
|
- "请求体字段必须且只能包含:" + "、".join(sorted(expected))
|
|
|
+ "请求体字段必须且只能包含:" + "、".join(sorted(allowed))
|
|
|
)
|
|
|
return payload
|
|
|
|
|
|
@@ -432,6 +451,39 @@ def _resolve_role(session, role_id: int | None) -> tuple[int | None, str | None]
|
|
|
return role.id, role.role_name
|
|
|
|
|
|
|
|
|
+def _optional_managed_org(session, value: Any) -> int | None:
|
|
|
+ """解析用户级管理范围三态(契约1.30)。
|
|
|
+
|
|
|
+ null/空串=全局、"SELF"=跟随本人所在机构(哨兵0)、机构ID=固定锚点;
|
|
|
+ 锚点须指向未删除机构(与角色锚点同口径,不要求启用、不限操作者范围)。
|
|
|
+ """
|
|
|
+ if value is None or value == "":
|
|
|
+ return None
|
|
|
+ if value == MANAGED_ORG_SELF:
|
|
|
+ return MANAGED_ORG_SELF_ANCHOR
|
|
|
+ if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
|
|
|
+ raise InvalidArgumentError("managedOrgId必须是SELF、机构ID或null")
|
|
|
+ org_id = int(value)
|
|
|
+ exists = session.scalar(
|
|
|
+ select(Organization.id).where(
|
|
|
+ Organization.id == org_id,
|
|
|
+ Organization.is_deleted.is_(False),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if exists is None:
|
|
|
+ raise InvalidArgumentError("managedOrgId必须指向有效机构")
|
|
|
+ return org_id
|
|
|
+
|
|
|
+
|
|
|
+def _managed_org_value(value: int | None) -> str | None:
|
|
|
+ """用户级管理范围序列化:NULL→null、哨兵0→"SELF"、其余→机构ID字符串。"""
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
+ if value == MANAGED_ORG_SELF_ANCHOR:
|
|
|
+ return MANAGED_ORG_SELF
|
|
|
+ return serialize_id(value)
|
|
|
+
|
|
|
+
|
|
|
def _ensure_not_self(actor: Any, target_user_id: int, *, action: str) -> None:
|
|
|
if actor.user_id == target_user_id:
|
|
|
raise UserSelfProtectionError(
|
|
|
@@ -480,7 +532,9 @@ def _ensure_not_last_active_admin(session, user: User, *, reason: str) -> None:
|
|
|
|
|
|
|
|
|
def create_user(payload: Any) -> dict[str, object]:
|
|
|
- body = _exact_payload(payload, CREATE_FIELDS)
|
|
|
+ body = _exact_payload(
|
|
|
+ payload, CREATE_FIELDS, optional=OPTIONAL_MANAGED_ORG_FIELDS
|
|
|
+ )
|
|
|
username = _required_text(body["username"], field="username", max_length=64)
|
|
|
real_name = _required_text(body["realName"], field="realName", max_length=64)
|
|
|
initial_password = _required_password(body["initialPassword"])
|
|
|
@@ -490,6 +544,13 @@ def create_user(payload: Any) -> dict[str, object]:
|
|
|
security_level = _security_level(body["securityLevel"])
|
|
|
status = _status_value(body["status"])
|
|
|
actor_id = get_auth_context().user_id
|
|
|
+ # 超级管理员登录开关(安全需求7)关闭时拒绝创建内置 admin 用户名:
|
|
|
+ # 报错不提示"已存在",不泄露系统中存在该账号
|
|
|
+ if (
|
|
|
+ username == SUPER_ADMIN_USERNAME
|
|
|
+ and not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True)
|
|
|
+ ):
|
|
|
+ raise InvalidArgumentError("不能创建该名称的用户")
|
|
|
|
|
|
try:
|
|
|
with transaction() as session:
|
|
|
@@ -507,6 +568,22 @@ def create_user(payload: Any) -> dict[str, object]:
|
|
|
raise ConflictError("用户名已存在")
|
|
|
org_id, org_name = _resolve_organization(session, organization_id)
|
|
|
resolved_role_id, resolved_role_name = _resolve_role(session, role_id)
|
|
|
+ # 缺省落 SELF(哨兵0):显式赋值不依赖数据库缺省,
|
|
|
+ # 保证未提交该字段的新建用户与迁移回填的存量行语义一致
|
|
|
+ managed_org_id = (
|
|
|
+ _optional_managed_org(session, body["managedOrgId"])
|
|
|
+ if "managedOrgId" in body
|
|
|
+ else MANAGED_ORG_SELF_ANCHOR
|
|
|
+ )
|
|
|
+ # 防提权(契约1.30修订):目标 ADMIN 的生效管理范围不得超出操作者
|
|
|
+ ensure_user_scope_in_managed_scope(
|
|
|
+ session,
|
|
|
+ actor,
|
|
|
+ role_code=role_code,
|
|
|
+ effective_role=effective_role_snapshot(session, resolved_role_id),
|
|
|
+ managed_org_id=managed_org_id,
|
|
|
+ organization_id=org_id,
|
|
|
+ )
|
|
|
user = User(
|
|
|
username=username,
|
|
|
password_hash=hash_password(initial_password),
|
|
|
@@ -517,6 +594,7 @@ def create_user(payload: Any) -> dict[str, object]:
|
|
|
role_id=resolved_role_id,
|
|
|
security_level=security_level,
|
|
|
status=status,
|
|
|
+ managed_org_id=managed_org_id,
|
|
|
created_by=actor_id,
|
|
|
updated_by=actor_id,
|
|
|
)
|
|
|
@@ -540,6 +618,7 @@ def create_user(payload: Any) -> dict[str, object]:
|
|
|
"roleName": resolved_role_name,
|
|
|
"securityLevel": user.security_level,
|
|
|
"status": user.status,
|
|
|
+ "managedOrgId": _managed_org_value(user.managed_org_id),
|
|
|
},
|
|
|
)
|
|
|
)
|
|
|
@@ -549,7 +628,9 @@ def create_user(payload: Any) -> dict[str, object]:
|
|
|
|
|
|
|
|
|
def update_user(user_id: int, payload: Any) -> dict[str, object]:
|
|
|
- body = _exact_payload(payload, UPDATE_FIELDS)
|
|
|
+ body = _exact_payload(
|
|
|
+ payload, UPDATE_FIELDS, optional=OPTIONAL_MANAGED_ORG_FIELDS
|
|
|
+ )
|
|
|
real_name = _required_text(body["realName"], field="realName", max_length=64)
|
|
|
organization_id = parse_string_id(
|
|
|
body["organizationId"], name="organizationId"
|
|
|
@@ -592,6 +673,7 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
|
|
|
"roleId": serialize_id(user.role_id),
|
|
|
"securityLevel": user.security_level,
|
|
|
"status": user.status,
|
|
|
+ "managedOrgId": _managed_org_value(user.managed_org_id),
|
|
|
"rowVersion": user.row_version,
|
|
|
}
|
|
|
|
|
|
@@ -613,6 +695,27 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
|
|
|
ensure_org_in_managed_scope(session, actor_user, org_id)
|
|
|
resolved_role_id, resolved_role_name = _resolve_role(session, new_role_id)
|
|
|
role_assignment_changes = resolved_role_id != user.role_id
|
|
|
+ # 可选字段(契约1.30):未提交保持原值;调整不改变认证身份,
|
|
|
+ # 不触发二次认证、不失效旧 Token(与角色锚点同口径)
|
|
|
+ new_managed_org_id = (
|
|
|
+ _optional_managed_org(session, body["managedOrgId"])
|
|
|
+ if "managedOrgId" in body
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ # 防提权(契约1.30修订):目标 ADMIN 最终生效管理范围不得超出操作者;
|
|
|
+ # 未提交 managedOrgId 时按保持原值判定。校验置于 reauth 之前,
|
|
|
+ # 避免换角色场景先抛 REAUTH_REQUIRED 掩盖越权拒绝。
|
|
|
+ final_managed_org_id = (
|
|
|
+ new_managed_org_id if "managedOrgId" in body else user.managed_org_id
|
|
|
+ )
|
|
|
+ ensure_user_scope_in_managed_scope(
|
|
|
+ session,
|
|
|
+ actor_user,
|
|
|
+ role_code=new_role_code,
|
|
|
+ effective_role=effective_role_snapshot(session, resolved_role_id),
|
|
|
+ managed_org_id=final_managed_org_id,
|
|
|
+ organization_id=org_id,
|
|
|
+ )
|
|
|
|
|
|
# 敏感字段(密级/角色/状态)实际变化:强制操作人二次认证(契约1.21)
|
|
|
if (
|
|
|
@@ -630,6 +733,8 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
|
|
|
user.role_id = resolved_role_id
|
|
|
user.security_level = new_security_level
|
|
|
user.status = new_status
|
|
|
+ if "managedOrgId" in body:
|
|
|
+ user.managed_org_id = new_managed_org_id
|
|
|
user.updated_by = actor_id
|
|
|
user.updated_at = now
|
|
|
user.row_version += 1
|
|
|
@@ -652,6 +757,7 @@ def update_user(user_id: int, payload: Any) -> dict[str, object]:
|
|
|
"roleId": serialize_id(user.role_id),
|
|
|
"securityLevel": user.security_level,
|
|
|
"status": user.status,
|
|
|
+ "managedOrgId": _managed_org_value(user.managed_org_id),
|
|
|
"rowVersion": user.row_version,
|
|
|
}
|
|
|
session.add(
|
|
|
@@ -780,6 +886,74 @@ def reset_user_password(user_id: int, payload: Any) -> dict[str, object]:
|
|
|
UI_THEMES = ("blue", "red", "green")
|
|
|
|
|
|
|
|
|
+REORDER_FIELDS = {"orderedIds"}
|
|
|
+
|
|
|
+
|
|
|
+def reorder_users(payload: Any) -> dict[str, object]:
|
|
|
+ """用户列表拖动重排(契约1.27):全体用户槽位置换,步长10重编号 sort_no。
|
|
|
+
|
|
|
+ orderedIds 允许为当前分页视图的子集,被提交用户按原槽位次序互换
|
|
|
+ (未提交用户相对次序不变),与分类同级重排的置换语义一致。
|
|
|
+ """
|
|
|
+ body = _exact_payload(payload, REORDER_FIELDS)
|
|
|
+ ordered_ids = body["orderedIds"]
|
|
|
+ if not isinstance(ordered_ids, list) or any(
|
|
|
+ not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
|
|
|
+ for item in ordered_ids
|
|
|
+ ):
|
|
|
+ raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
|
|
|
+ if len(set(ordered_ids)) != len(ordered_ids):
|
|
|
+ raise InvalidArgumentError("orderedIds存在重复ID")
|
|
|
+ if len(ordered_ids) < 2:
|
|
|
+ raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
|
|
|
+ actor_id = get_auth_context().user_id
|
|
|
+ now = _utc_now()
|
|
|
+
|
|
|
+ with transaction() as session:
|
|
|
+ require_user_editor(session)
|
|
|
+ rows = session.scalars(
|
|
|
+ select(User)
|
|
|
+ .where(User.is_deleted.is_(False))
|
|
|
+ .order_by(User.sort_no.asc(), User.id.asc())
|
|
|
+ .with_for_update()
|
|
|
+ ).all()
|
|
|
+ row_by_id = {row.id: row for row in rows}
|
|
|
+ all_ids = [row.id for row in rows]
|
|
|
+ ordered = [int(item) for item in ordered_ids]
|
|
|
+ if any(user_id not in row_by_id for user_id in ordered):
|
|
|
+ raise ResourceNotFoundError("用户不存在")
|
|
|
+ # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
|
|
|
+ slot_ids = sorted(ordered, key=all_ids.index)
|
|
|
+ new_order = list(all_ids)
|
|
|
+ for position, user_id in enumerate(slot_ids):
|
|
|
+ new_order[all_ids.index(user_id)] = ordered[position]
|
|
|
+ reordered_count = 0
|
|
|
+ for index, user_id in enumerate(new_order):
|
|
|
+ row = row_by_id[user_id]
|
|
|
+ target = (index + 1) * 10
|
|
|
+ if row.sort_no == target:
|
|
|
+ continue
|
|
|
+ row.sort_no = target
|
|
|
+ row.updated_by = actor_id
|
|
|
+ row.updated_at = now
|
|
|
+ row.row_version += 1
|
|
|
+ reordered_count += 1
|
|
|
+ first = row_by_id[ordered[0]]
|
|
|
+ session.add(
|
|
|
+ business_audit(
|
|
|
+ action=AuditAction.REORDER_USER,
|
|
|
+ target=AuditTarget.USER,
|
|
|
+ target_id=first.id,
|
|
|
+ target_name=first.real_name,
|
|
|
+ detail={
|
|
|
+ "orderedIds": list(ordered_ids),
|
|
|
+ "reorderedCount": reordered_count,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return {"reorderedCount": reordered_count}
|
|
|
+
|
|
|
+
|
|
|
def update_ui_theme(payload: Any) -> dict[str, object]:
|
|
|
"""本人界面主题偏好(契约1.20):白名单校验,仅操作本人行。
|
|
|
|