agent_governance_routes.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. """Governed Agent registry, authorization decisions and replay APIs."""
  2. from __future__ import annotations
  3. import hashlib
  4. from flask import current_app, g, jsonify, request
  5. from app import db
  6. from app.api.knowledge_base import bp
  7. from app.config.config import is_placeholder_env_value
  8. from app.core.llm.agent_governance import AgentGovernanceService
  9. from app.core.llm.agent_governance_repository import (
  10. SqlAlchemyAgentGovernanceRepository,
  11. WorkCenterAgentApprovalGateway,
  12. )
  13. from app.core.system.permissions import (
  14. AGENTS_MANAGE,
  15. AGENTS_OPERATE,
  16. AGENTS_READ,
  17. require_permissions,
  18. )
  19. from app.models.result import failed, success
  20. class AgentGovernanceUnavailable(RuntimeError):
  21. """Raised when production lacks a dedicated credential signing secret."""
  22. def _effective_credential_secret() -> tuple[str, bool]:
  23. dedicated = str(current_app.config.get("AGENT_CREDENTIAL_SECRET") or "").strip()
  24. if len(dedicated.encode()) >= 32 and not is_placeholder_env_value(dedicated):
  25. return dedicated, True
  26. fallback = hashlib.sha256(
  27. ("dataops-wp09-local-agent:" + str(current_app.config.get("SECRET_KEY") or "")).encode()
  28. ).hexdigest()
  29. return fallback, False
  30. def _require_credential_secret() -> None:
  31. _secret, dedicated_ready = _effective_credential_secret()
  32. if (
  33. not dedicated_ready
  34. and not current_app.config.get("TESTING")
  35. and str(current_app.config.get("FLASK_ENV") or "").lower() == "production"
  36. ):
  37. raise AgentGovernanceUnavailable(
  38. "dedicated Agent credential secret is required in production"
  39. )
  40. def _service():
  41. secret, _dedicated_ready = _effective_credential_secret()
  42. return AgentGovernanceService(
  43. SqlAlchemyAgentGovernanceRepository(db.session),
  44. approval_gateway=WorkCenterAgentApprovalGateway(db.session),
  45. credential_secret=secret,
  46. commit=db.session.commit,
  47. rollback=db.session.rollback,
  48. )
  49. def _expected_version() -> int:
  50. raw = str(request.headers.get("If-Match") or "").strip()
  51. if raw.startswith("W/"):
  52. raw = raw[2:].strip()
  53. raw = raw.strip('"')
  54. if not raw.isdigit():
  55. raise ValueError("missing valid If-Match version")
  56. return int(raw)
  57. def _etag(response, version):
  58. response.headers["ETag"] = f'"{int(version)}"'
  59. return response
  60. def _no_store(response):
  61. response.headers["Cache-Control"] = "no-store"
  62. return response
  63. def _error(exc):
  64. db.session.rollback()
  65. if "If-Match" in str(exc):
  66. status = 428
  67. elif isinstance(exc, LookupError):
  68. status = 404
  69. elif isinstance(exc, (PermissionError, AgentGovernanceUnavailable)):
  70. status = 403 if isinstance(exc, PermissionError) else 503
  71. elif isinstance(exc, RuntimeError):
  72. status = 409
  73. else:
  74. status = 400
  75. return jsonify(failed(str(exc), code=status)), status
  76. @bp.route("/agents", methods=["GET"])
  77. @require_permissions(AGENTS_READ)
  78. def list_governed_agents():
  79. filters = {
  80. key: request.args.get(key)
  81. for key in ("status", "autonomy_level", "owner_uid")
  82. if request.args.get(key)
  83. }
  84. return jsonify(success(_service().list_agents(**filters)))
  85. @bp.route("/agents", methods=["POST"])
  86. @require_permissions(AGENTS_MANAGE)
  87. def register_governed_agent():
  88. try:
  89. result = _service().register_agent(
  90. request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
  91. )
  92. return _etag(jsonify(success(result, "Agent 治理登记已创建", code=201)), 1), 201
  93. except (ValueError, LookupError, RuntimeError) as exc:
  94. return _error(exc)
  95. @bp.route("/agents/<agent_uid>", methods=["GET"])
  96. @require_permissions(AGENTS_READ)
  97. def get_governed_agent(agent_uid):
  98. try:
  99. result = _service().agent_detail(agent_uid)
  100. return _etag(jsonify(success(result)), result["current_version"])
  101. except (ValueError, LookupError) as exc:
  102. return _error(exc)
  103. @bp.route("/agents/<agent_uid>/revisions", methods=["POST"])
  104. @require_permissions(AGENTS_OPERATE)
  105. def revise_governed_agent(agent_uid):
  106. try:
  107. result = _service().revise_agent(
  108. agent_uid, request.get_json(silent=True) or {},
  109. expected_version=_expected_version(), actor_uid=g.current_user["id"],
  110. )
  111. return _etag(jsonify(success(result, "Agent 版本草稿已创建")), result["current_version"])
  112. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  113. return _error(exc)
  114. @bp.route("/agents/<agent_uid>/transition", methods=["POST"])
  115. @require_permissions(AGENTS_OPERATE)
  116. def transition_governed_agent(agent_uid):
  117. try:
  118. result = _service().transition_agent(
  119. agent_uid, request.get_json(silent=True) or {},
  120. expected_version=_expected_version(), actor_uid=g.current_user["id"],
  121. )
  122. return _etag(jsonify(success(result, "Agent 生命周期已更新")), result["current_version"])
  123. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  124. return _error(exc)
  125. @bp.route("/agents/<agent_uid>/grants", methods=["POST"])
  126. @require_permissions(AGENTS_OPERATE)
  127. def create_agent_tool_grant(agent_uid):
  128. try:
  129. result = _service().create_tool_grant(
  130. agent_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
  131. )
  132. return jsonify(success(result, "Agent 工具授权已创建", code=201)), 201
  133. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  134. return _error(exc)
  135. @bp.route("/agents/<agent_uid>/grants/<grant_uid>/revoke", methods=["POST"])
  136. @require_permissions(AGENTS_OPERATE)
  137. def revoke_agent_tool_grant(agent_uid, grant_uid):
  138. try:
  139. return jsonify(success(_service().revoke_tool_grant(
  140. agent_uid, grant_uid, actor_uid=g.current_user["id"]
  141. ), "Agent 工具授权已撤销"))
  142. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  143. return _error(exc)
  144. @bp.route("/agents/<agent_uid>/credentials", methods=["POST"])
  145. @require_permissions(AGENTS_OPERATE)
  146. def issue_agent_credential(agent_uid):
  147. try:
  148. _require_credential_secret()
  149. result = _service().issue_credential(
  150. agent_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
  151. )
  152. return _no_store(jsonify(success(result, "凭证仅本次返回,请立即安全保存", code=201))), 201
  153. except (ValueError, LookupError, PermissionError, RuntimeError, AgentGovernanceUnavailable) as exc:
  154. return _error(exc)
  155. @bp.route("/agents/<agent_uid>/credentials/revoke", methods=["POST"])
  156. @require_permissions(AGENTS_OPERATE)
  157. def revoke_agent_credentials(agent_uid):
  158. try:
  159. return jsonify(success(_service().revoke_agent_credentials(
  160. agent_uid, actor_uid=g.current_user["id"]
  161. ), "Agent 有效凭证已全部撤销"))
  162. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  163. return _error(exc)
  164. @bp.route("/agents/<agent_uid>/actions/authorize", methods=["POST"])
  165. @require_permissions(AGENTS_OPERATE)
  166. def authorize_agent_action(agent_uid):
  167. try:
  168. _require_credential_secret()
  169. token = str(request.headers.get("X-Agent-Credential") or "")
  170. result = _service().authorize_action(
  171. agent_uid, token, request.get_json(silent=True) or {}
  172. )
  173. return _no_store(_etag(jsonify(success(result, "Agent 策略判定已留痕", code=201)), 1)), 201
  174. except (ValueError, LookupError, PermissionError, RuntimeError, AgentGovernanceUnavailable) as exc:
  175. return _error(exc)
  176. @bp.route("/agents/actions", methods=["GET"])
  177. @require_permissions(AGENTS_READ)
  178. def list_agent_actions():
  179. filters = {
  180. key: request.args.get(key)
  181. for key in ("agent_uid", "decision", "risk_level")
  182. if request.args.get(key)
  183. }
  184. return jsonify(success(_service().list_requests(**filters)))
  185. @bp.route("/agents/actions/<request_uid>/reconcile", methods=["POST"])
  186. @require_permissions(AGENTS_MANAGE)
  187. def reconcile_agent_action(request_uid):
  188. try:
  189. result = _service().reconcile_action(
  190. request_uid, expected_version=_expected_version(), actor_uid=g.current_user["id"]
  191. )
  192. return _etag(jsonify(success(result, "审批结果已同步")), result["current_version"])
  193. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  194. return _error(exc)
  195. @bp.route("/agents/actions/<request_uid>/complete", methods=["POST"])
  196. @require_permissions(AGENTS_OPERATE)
  197. def complete_agent_action(request_uid):
  198. try:
  199. result = _service().complete_action(
  200. request_uid, request.get_json(silent=True) or {},
  201. expected_version=_expected_version(), actor_uid=g.current_user["id"],
  202. )
  203. return _etag(jsonify(success(result, "Agent 执行证据已记录")), result["current_version"])
  204. except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
  205. return _error(exc)
  206. @bp.route("/agents/actions/<request_uid>/replay", methods=["GET"])
  207. @require_permissions(AGENTS_READ)
  208. def replay_agent_action(request_uid):
  209. try:
  210. return _no_store(jsonify(success(_service().replay(request_uid))))
  211. except (ValueError, LookupError) as exc:
  212. return _error(exc)
  213. @bp.route("/agents/dashboard", methods=["GET"])
  214. @require_permissions(AGENTS_READ)
  215. def agent_governance_dashboard():
  216. try:
  217. return jsonify(success(_service().dashboard()))
  218. except AgentGovernanceUnavailable as exc:
  219. return _error(exc)