users.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. from __future__ import annotations
  2. import json
  3. from flask import g, jsonify, request
  4. from sqlalchemy import text
  5. from sqlalchemy.exc import IntegrityError
  6. from app import db
  7. from app.api.system import bp
  8. from app.commands.bootstrap_admin import validate_password
  9. from app.core.common.identifiers import new_governance_uid
  10. from app.core.system.auth import hash_password
  11. from app.core.system.permissions import MANAGE_USERS, require_permissions
  12. from app.models.result import failed, success
  13. VALID_ROLES = {"admin", "editor", "viewer"}
  14. def _audit_access_control(action, resource_uid, status, safe_detail):
  15. db.session.execute(
  16. text(
  17. "INSERT INTO public.access_control_audit_events "
  18. "(uid,action,actor_uid,resource_type,resource_uid,status,safe_detail) VALUES "
  19. "(CAST(:uid AS uuid),:action,CAST(:actor AS uuid),'user',:resource_uid,:status,CAST(:detail AS jsonb))"
  20. ),
  21. {
  22. "uid": new_governance_uid(),
  23. "action": action,
  24. "actor": g.current_user["id"],
  25. "resource_uid": resource_uid,
  26. "status": status,
  27. "detail": json.dumps(safe_detail, ensure_ascii=False),
  28. },
  29. )
  30. def _active_admin_count(session) -> int:
  31. return int(
  32. session.execute(
  33. text(
  34. "SELECT COUNT(DISTINCT u.id) FROM public.users u "
  35. "JOIN public.user_roles ur ON ur.user_id = u.id "
  36. "JOIN public.roles r ON r.id = ur.role_id "
  37. "WHERE u.status = 'active' AND r.name = 'admin'"
  38. )
  39. ).scalar_one()
  40. )
  41. def _is_active_admin(session, user_id: str) -> bool:
  42. return bool(
  43. session.execute(
  44. text(
  45. "SELECT 1 FROM public.users u "
  46. "JOIN public.user_roles ur ON ur.user_id = u.id "
  47. "JOIN public.roles r ON r.id = ur.role_id "
  48. "WHERE u.id = CAST(:id AS uuid) AND u.status = 'active' "
  49. "AND r.name = 'admin'"
  50. ),
  51. {"id": user_id},
  52. ).scalar()
  53. )
  54. def _serialize_users(session) -> list[dict]:
  55. rows = session.execute(
  56. text(
  57. "SELECT u.id::text, u.username, u.display_name, u.status, "
  58. "u.created_at, u.last_login_at, "
  59. "COALESCE(array_agg(r.name ORDER BY r.name) FILTER "
  60. "(WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) "
  61. "FROM public.users u "
  62. "LEFT JOIN public.user_roles ur ON ur.user_id = u.id "
  63. "LEFT JOIN public.roles r ON r.id = ur.role_id "
  64. "GROUP BY u.id ORDER BY u.created_at, u.username"
  65. )
  66. )
  67. return [
  68. {
  69. "id": row[0],
  70. "username": row[1],
  71. "display_name": row[2],
  72. "status": row[3],
  73. "created_at": row[4].isoformat(),
  74. "last_login_at": row[5].isoformat() if row[5] else None,
  75. "roles": list(row[6]),
  76. }
  77. for row in rows
  78. ]
  79. @bp.route("/users", methods=["GET"])
  80. @require_permissions(MANAGE_USERS)
  81. def list_users():
  82. return jsonify(success(_serialize_users(db.session)))
  83. @bp.route("/users", methods=["POST"])
  84. @require_permissions(MANAGE_USERS)
  85. def create_user():
  86. body = request.get_json(silent=True) or {}
  87. username = str(body.get("username") or "").strip()
  88. password = str(body.get("password") or "")
  89. roles = set(body.get("roles") or ["viewer"])
  90. if not username or not roles or not roles <= VALID_ROLES:
  91. return jsonify(failed("用户名或角色无效", code=400)), 400
  92. try:
  93. validate_password(password)
  94. user_id = new_governance_uid()
  95. db.session.execute(
  96. text(
  97. "INSERT INTO public.users (id, username, display_name, password_hash) "
  98. "VALUES (CAST(:id AS uuid), :username, :display_name, :password_hash)"
  99. ),
  100. {
  101. "id": user_id,
  102. "username": username,
  103. "display_name": body.get("display_name") or username,
  104. "password_hash": hash_password(password),
  105. },
  106. )
  107. db.session.execute(
  108. text(
  109. "INSERT INTO public.user_roles (user_id, role_id, assigned_by) "
  110. "SELECT CAST(:user_id AS uuid), id, CAST(:assigned_by AS uuid) "
  111. "FROM public.roles WHERE name = ANY(:roles)"
  112. ),
  113. {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
  114. )
  115. _audit_access_control(
  116. "user_created", user_id, "success", {"roles": sorted(roles)}
  117. )
  118. db.session.commit()
  119. return jsonify(success({"id": user_id}, "用户创建成功", code=201)), 201
  120. except (ValueError, IntegrityError) as exc:
  121. db.session.rollback()
  122. return jsonify(failed(str(exc), code=400)), 400
  123. @bp.route("/users/<user_id>", methods=["PUT"])
  124. @require_permissions(MANAGE_USERS)
  125. def update_user(user_id: str):
  126. body = request.get_json(silent=True) or {}
  127. status = body.get("status")
  128. if status not in (None, "active", "disabled"):
  129. return jsonify(failed("用户状态无效", code=400)), 400
  130. if (
  131. status == "disabled"
  132. and _is_active_admin(db.session, user_id)
  133. and _active_admin_count(db.session) <= 1
  134. ):
  135. return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
  136. values = {"id": user_id}
  137. assignments = []
  138. if status is not None:
  139. assignments.append("status = :status")
  140. values["status"] = status
  141. if "display_name" in body:
  142. assignments.append("display_name = :display_name")
  143. values["display_name"] = str(body.get("display_name") or "")[:100] or None
  144. if "password" in body:
  145. validate_password(str(body["password"]))
  146. assignments.append("password_hash = :password_hash")
  147. values["password_hash"] = hash_password(str(body["password"]))
  148. if not assignments:
  149. return jsonify(failed("没有可更新字段", code=400)), 400
  150. result = db.session.execute(
  151. text(
  152. "UPDATE public.users SET " + ", ".join(assignments) +
  153. ", updated_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"
  154. ),
  155. values,
  156. )
  157. if not result.rowcount:
  158. db.session.rollback()
  159. return jsonify(failed("用户不存在", code=404)), 404
  160. _audit_access_control(
  161. "user_updated",
  162. user_id,
  163. "success",
  164. {
  165. "changed_fields": sorted(
  166. key for key in ("status", "display_name", "password") if key in body
  167. ),
  168. "password_value_retained": False,
  169. },
  170. )
  171. db.session.commit()
  172. return jsonify(success(message="用户更新成功"))
  173. @bp.route("/users/<user_id>/roles", methods=["PUT"])
  174. @require_permissions(MANAGE_USERS)
  175. def update_user_roles(user_id: str):
  176. body = request.get_json(silent=True) or {}
  177. roles = set(body.get("roles") or [])
  178. if not roles or not roles <= VALID_ROLES:
  179. return jsonify(failed("角色无效", code=400)), 400
  180. if (
  181. _is_active_admin(db.session, user_id)
  182. and "admin" not in roles
  183. and _active_admin_count(db.session) <= 1
  184. ):
  185. return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
  186. previous_roles = list(
  187. db.session.execute(
  188. text(
  189. "SELECT r.name FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id "
  190. "WHERE ur.user_id=CAST(:id AS uuid) ORDER BY r.name"
  191. ),
  192. {"id": user_id},
  193. ).scalars()
  194. )
  195. db.session.execute(
  196. text("DELETE FROM public.user_roles WHERE user_id = CAST(:id AS uuid)"),
  197. {"id": user_id},
  198. )
  199. result = db.session.execute(
  200. text(
  201. "INSERT INTO public.user_roles (user_id, role_id, assigned_by) "
  202. "SELECT CAST(:user_id AS uuid), id, CAST(:assigned_by AS uuid) "
  203. "FROM public.roles WHERE name = ANY(:roles)"
  204. ),
  205. {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
  206. )
  207. if result.rowcount != len(roles):
  208. db.session.rollback()
  209. return jsonify(failed("用户或角色不存在", code=404)), 404
  210. _audit_access_control(
  211. "user_roles_updated",
  212. user_id,
  213. "success",
  214. {"before_roles": previous_roles, "after_roles": sorted(roles)},
  215. )
  216. db.session.commit()
  217. return jsonify(success(message="角色更新成功"))