users.py 10.0 KB

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