20260813_497_agent_runtime_critical_hardening.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """Harden WP09 runtime authorization into one fenced database transaction."""
  2. from alembic import op
  3. revision = "20260813_497"
  4. down_revision = "20260813_496"
  5. branch_labels = None
  6. depends_on = None
  7. def upgrade() -> None:
  8. op.execute(r"""
  9. DO $$ BEGIN
  10. IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_agent_runtime_owner')
  11. OR NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_agent_runtime') THEN
  12. RAISE EXCEPTION 'agent runtime role-init is required before migration 20260813_497';
  13. END IF;
  14. IF NOT pg_has_role(current_user, 'dataops_agent_runtime_owner', 'MEMBER') THEN
  15. RAISE EXCEPTION 'migrator must be pre-granted agent runtime owner role';
  16. END IF;
  17. END $$;
  18. ALTER TABLE public.agent_invocation_audits
  19. ADD COLUMN IF NOT EXISTS request_digest char(64),
  20. ADD COLUMN IF NOT EXISTS approval_task_uid uuid REFERENCES public.governance_tasks(uid),
  21. ADD COLUMN IF NOT EXISTS incident_uid uuid REFERENCES public.data_incidents(uid);
  22. UPDATE public.agent_invocation_audits SET request_digest=input_hash WHERE request_digest IS NULL;
  23. ALTER TABLE public.agent_invocation_audits ALTER COLUMN request_digest SET NOT NULL;
  24. CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_runtime_request_digest
  25. ON public.agent_invocation_audits(tenant_id,idempotency_key,request_digest);
  26. ALTER TABLE public.agent_runtime_reservations
  27. ADD COLUMN IF NOT EXISTS settled_at timestamptz,
  28. ADD COLUMN IF NOT EXISTS settlement text;
  29. CREATE OR REPLACE FUNCTION public.agent_runtime_approval_valid(
  30. p_task uuid,p_actor uuid,p_domain uuid,p_environment text,p_tool text,p_action text,p_digest text
  31. ) RETURNS boolean LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog,public AS $agent_runtime_approval$
  32. SELECT EXISTS(
  33. SELECT 1 FROM public.governance_tasks t
  34. WHERE t.uid=p_task AND t.status='approved'
  35. AND t.business_domain_uid=p_domain::text AND t.context->>'environment'=p_environment
  36. AND t.context->>'tool_name'=p_tool AND t.context->>'action'=p_action
  37. AND t.context->>'request_digest'=p_digest AND t.due_at>clock_timestamp()
  38. AND t.context->>'automatic_execution_allowed'='false'
  39. AND EXISTS(SELECT 1 FROM public.governance_task_reviews r
  40. WHERE r.task_uid=t.uid AND r.decision='approve' AND r.reviewer_uid<>p_actor)
  41. )
  42. $agent_runtime_approval$;
  43. CREATE OR REPLACE FUNCTION public.agent_runtime_authorize(p_payload jsonb)
  44. RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER
  45. SET search_path=pg_catalog,public AS $agent_runtime_authorize$
  46. DECLARE v_budget record; v_model record; v_grant record; v_route record;
  47. v_audit record; v_fence bigint; v_now timestamptz; v_approval uuid;
  48. BEGIN
  49. IF jsonb_typeof(p_payload) <> 'object'
  50. OR NOT (p_payload ?& ARRAY['agent_uid','grant_uid','tenant_id','principal_id',
  51. 'business_domain_uid','environment','provider','model','prompt_version',
  52. 'generation','interface_type','tool_name','action','risk_level','idempotency_key',
  53. 'request_digest','input_hash','evidence_digests','estimated_tokens',
  54. 'estimated_cost_micros','requested_time_ms'])
  55. OR p_payload - ARRAY['agent_uid','grant_uid','tenant_id','principal_id',
  56. 'business_domain_uid','environment','provider','model','prompt_version',
  57. 'generation','interface_type','tool_name','action','risk_level','idempotency_key',
  58. 'request_digest','input_hash','evidence_digests','estimated_tokens',
  59. 'estimated_cost_micros','requested_time_ms','approval_task_uid'] <> '{}'::jsonb THEN
  60. RAISE EXCEPTION 'agent runtime request is closed';
  61. END IF;
  62. IF (p_payload->>'request_digest') !~ '^[0-9a-f]{64}$'
  63. OR (p_payload->>'input_hash') !~ '^[0-9a-f]{64}$'
  64. OR jsonb_typeof(p_payload->'evidence_digests') <> 'array'
  65. OR (p_payload->>'estimated_tokens') !~ '^[1-9][0-9]{0,5}$'
  66. OR (p_payload->>'estimated_cost_micros') !~ '^[1-9][0-9]{0,8}$'
  67. OR (p_payload->>'requested_time_ms') !~ '^[1-9][0-9]{0,5}$' THEN
  68. RAISE EXCEPTION 'agent runtime budget value is invalid';
  69. END IF;
  70. SELECT g.*,a.owner_uid INTO v_grant FROM public.agent_tool_grants g
  71. JOIN public.governed_agents a ON a.uid=g.agent_uid
  72. WHERE g.uid=(p_payload->>'grant_uid')::uuid
  73. AND g.agent_uid=(p_payload->>'agent_uid')::uuid
  74. AND g.status='active' AND a.status='active'
  75. AND a.owner_uid=(p_payload->>'principal_id')::uuid
  76. AND g.business_domain_uid=(p_payload->>'business_domain_uid')::uuid
  77. AND g.environment=p_payload->>'environment'
  78. AND g.interface_type=p_payload->>'interface_type'
  79. AND g.tool_name=p_payload->>'tool_name' AND g.action=p_payload->>'action'
  80. AND g.risk_level=p_payload->>'risk_level' FOR UPDATE;
  81. IF NOT FOUND THEN
  82. RAISE EXCEPTION 'agent runtime grant scope denied';
  83. END IF;
  84. IF v_grant.action='execute' OR v_grant.risk_level <> 'low' OR v_grant.requires_approval THEN
  85. IF NOT (p_payload ? 'approval_task_uid') THEN RAISE EXCEPTION 'agent runtime approval is missing'; END IF;
  86. v_approval := (p_payload->>'approval_task_uid')::uuid;
  87. END IF;
  88. SELECT * INTO v_route FROM public.model_gateway_routes
  89. WHERE tenant_id=p_payload->>'tenant_id'
  90. AND principal_id=(p_payload->>'principal_id')::uuid
  91. AND business_domain_uid=(p_payload->>'business_domain_uid')::uuid
  92. AND environment=p_payload->>'environment' AND provider=p_payload->>'provider'
  93. AND model=p_payload->>'model' AND prompt_version=p_payload->>'prompt_version'
  94. AND generation=p_payload->>'generation' AND canary_status='approved' FOR UPDATE;
  95. IF NOT FOUND THEN RAISE EXCEPTION 'agent runtime route scope denied'; END IF;
  96. SELECT * INTO v_budget FROM public.agent_runtime_budgets
  97. WHERE tenant_id=p_payload->>'tenant_id' FOR UPDATE;
  98. IF NOT FOUND THEN RAISE EXCEPTION 'agent runtime budget missing'; END IF;
  99. SELECT * INTO v_audit FROM public.agent_invocation_audits
  100. WHERE tenant_id=p_payload->>'tenant_id' AND idempotency_key=p_payload->>'idempotency_key' FOR UPDATE;
  101. IF FOUND THEN
  102. IF v_audit.request_digest <> p_payload->>'request_digest' THEN
  103. RAISE EXCEPTION 'agent runtime idempotency conflict';
  104. END IF;
  105. RETURN jsonb_build_object('decision',v_audit.decision,'invocation_id',v_audit.invocation_id::text,
  106. 'lease_fence',v_audit.lease_fence,'replay',true);
  107. END IF;
  108. IF v_approval IS NOT NULL AND NOT public.agent_runtime_approval_valid(
  109. v_approval,(p_payload->>'principal_id')::uuid,(p_payload->>'business_domain_uid')::uuid,
  110. p_payload->>'environment',p_payload->>'tool_name',p_payload->>'action',p_payload->>'input_hash') THEN
  111. RAISE EXCEPTION 'agent runtime approval is expired, cross-scope, or consumed';
  112. END IF;
  113. SELECT * INTO v_model FROM public.agent_runtime_model_budgets
  114. WHERE tenant_id=v_budget.tenant_id AND model=p_payload->>'model' FOR UPDATE;
  115. IF NOT FOUND OR v_budget.token_remaining < (p_payload->>'estimated_tokens')::bigint
  116. OR v_budget.cost_remaining_micros < (p_payload->>'estimated_cost_micros')::bigint
  117. OR v_budget.tool_remaining < 1 OR v_budget.time_remaining_ms < (p_payload->>'requested_time_ms')::bigint
  118. OR v_budget.concurrency_remaining < 1 OR v_model.remaining < 1 THEN
  119. RAISE EXCEPTION 'agent runtime budget exhausted';
  120. END IF;
  121. IF v_approval IS NOT NULL THEN
  122. UPDATE public.governance_tasks SET status='closed',closed_at=clock_timestamp(),
  123. updated_at=clock_timestamp()
  124. WHERE uid=v_approval AND status='approved' RETURNING uid INTO v_approval;
  125. IF NOT FOUND THEN RAISE EXCEPTION 'agent runtime approval was already consumed'; END IF;
  126. END IF;
  127. v_now:=clock_timestamp();
  128. UPDATE public.agent_runtime_budgets SET token_remaining=token_remaining-(p_payload->>'estimated_tokens')::bigint,
  129. cost_remaining_micros=cost_remaining_micros-(p_payload->>'estimated_cost_micros')::bigint,
  130. tool_remaining=tool_remaining-1,time_remaining_ms=time_remaining_ms-(p_payload->>'requested_time_ms')::bigint,
  131. concurrency_remaining=concurrency_remaining-1,lease_fence=lease_fence+1,updated_at=v_now
  132. WHERE tenant_id=v_budget.tenant_id RETURNING lease_fence INTO v_fence;
  133. UPDATE public.agent_runtime_model_budgets SET remaining=remaining-1
  134. WHERE tenant_id=v_budget.tenant_id AND model=v_model.model;
  135. INSERT INTO public.agent_runtime_reservations(reservation_id,tenant_id,idempotency_key,model,token_count,cost_micros,tool_count,time_ms,lease_fence,status)
  136. VALUES(gen_random_uuid(),v_budget.tenant_id,p_payload->>'idempotency_key',v_model.model,
  137. (p_payload->>'estimated_tokens')::bigint,(p_payload->>'estimated_cost_micros')::bigint,1,
  138. (p_payload->>'requested_time_ms')::bigint,v_fence,'reserved');
  139. INSERT INTO public.agent_invocation_audits(invocation_id,tenant_id,idempotency_key,route_id,generation,lease_fence,input_hash,evidence_digests,decision,request_digest,approval_task_uid)
  140. VALUES(gen_random_uuid(),v_budget.tenant_id,p_payload->>'idempotency_key',v_route.route_id,
  141. v_route.generation,v_fence,p_payload->>'input_hash',p_payload->'evidence_digests','authorized',p_payload->>'request_digest',v_approval)
  142. RETURNING * INTO v_audit;
  143. RETURN jsonb_build_object('decision','authorized','invocation_id',v_audit.invocation_id::text,
  144. 'lease_fence',v_fence,'replay',false);
  145. END; $agent_runtime_authorize$;
  146. REVOKE ALL ON TABLE public.agent_invocation_audits,public.agent_runtime_budgets,
  147. public.agent_runtime_model_budgets,public.agent_runtime_reservations FROM PUBLIC,dataops_agent_runtime;
  148. REVOKE ALL ON FUNCTION public.agent_runtime_authorize(jsonb) FROM PUBLIC;
  149. REVOKE ALL ON FUNCTION public.agent_runtime_approval_valid(uuid,uuid,uuid,text,text,text,text) FROM PUBLIC;
  150. GRANT SELECT,INSERT,UPDATE ON TABLE public.agent_invocation_audits,public.agent_runtime_budgets,
  151. public.agent_runtime_model_budgets,public.agent_runtime_reservations,
  152. public.agent_tool_grants,public.governed_agents,public.model_gateway_routes,
  153. public.governance_tasks,public.governance_task_reviews TO dataops_agent_runtime_owner;
  154. -- Role-init, not this low-privilege migration, grants dataops_agent_runtime to dataops.
  155. -- Schema usage is pre-provisioned by role-init for the runtime role.
  156. GRANT EXECUTE ON FUNCTION public.agent_runtime_authorize(jsonb) TO dataops_agent_runtime,dataops;
  157. ALTER FUNCTION public.agent_runtime_authorize(jsonb) OWNER TO dataops_agent_runtime_owner;
  158. ALTER FUNCTION public.agent_runtime_approval_valid(uuid,uuid,uuid,text,text,text,text) OWNER TO dataops_agent_runtime_owner;
  159. CREATE OR REPLACE FUNCTION public.agent_runtime_transition(
  160. p_tenant text,p_state text,p_incident uuid,p_approval uuid,p_expected_fence bigint
  161. ) RETURNS bigint LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $agent_runtime_transition$
  162. DECLARE v_fence bigint;
  163. BEGIN
  164. IF p_state NOT IN ('degraded','paused','active') THEN RAISE EXCEPTION 'runtime state invalid'; END IF;
  165. IF p_state='active' AND (p_approval IS NULL OR NOT EXISTS(SELECT 1 FROM governance_tasks WHERE uid=p_approval AND status='approved' AND due_at>clock_timestamp())) THEN RAISE EXCEPTION 'runtime recovery approval required'; END IF;
  166. IF p_state IN ('degraded','paused') AND p_incident IS NULL THEN RAISE EXCEPTION 'runtime incident required'; END IF;
  167. UPDATE agent_runtime_states SET state=p_state,incident_ref=COALESCE(p_incident::text,incident_ref),approval_ref=p_approval::text,lease_fence=lease_fence+1,updated_at=clock_timestamp()
  168. WHERE tenant_id=p_tenant AND lease_fence=p_expected_fence RETURNING lease_fence INTO v_fence;
  169. IF NOT FOUND THEN RAISE EXCEPTION 'runtime state fence conflict'; END IF;
  170. RETURN v_fence;
  171. END; $agent_runtime_transition$;
  172. CREATE OR REPLACE FUNCTION public.agent_runtime_canary_transition(
  173. p_tenant text,p_generation text,p_status text,p_approval uuid,p_expected_fence bigint,p_metrics jsonb,p_dataset_version text
  174. ) RETURNS bigint LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $agent_runtime_canary_transition$
  175. DECLARE v_fence bigint;
  176. BEGIN
  177. IF p_status NOT IN ('approved','rolled_back') OR p_dataset_version !~ '^[A-Za-z0-9._-]{1,120}$' OR jsonb_typeof(p_metrics)<>'object' THEN RAISE EXCEPTION 'canary evidence invalid'; END IF;
  178. IF p_approval IS NULL OR NOT EXISTS(SELECT 1 FROM governance_tasks WHERE uid=p_approval AND status='approved' AND due_at>clock_timestamp()) THEN RAISE EXCEPTION 'canary approval required'; END IF;
  179. UPDATE agent_generation_canaries SET status=p_status,approval_ref=p_approval::text,lease_fence=lease_fence+1
  180. WHERE tenant_id=p_tenant AND generation=p_generation AND lease_fence=p_expected_fence RETURNING lease_fence INTO v_fence;
  181. IF NOT FOUND THEN RAISE EXCEPTION 'generation fence conflict'; END IF;
  182. RETURN v_fence;
  183. END; $agent_runtime_canary_transition$;
  184. """)
  185. def downgrade() -> None:
  186. op.execute("""
  187. REVOKE dataops_agent_runtime FROM dataops;
  188. REVOKE EXECUTE ON FUNCTION public.agent_runtime_authorize(jsonb) FROM dataops_agent_runtime,dataops;
  189. DROP FUNCTION public.agent_runtime_authorize(jsonb);
  190. DROP FUNCTION public.agent_runtime_approval_valid(uuid,uuid,uuid,text,text,text,text);
  191. DROP FUNCTION IF EXISTS public.agent_runtime_transition(text,text,uuid,uuid,bigint);
  192. DROP FUNCTION IF EXISTS public.agent_runtime_canary_transition(text,text,text,uuid,bigint,jsonb,text);
  193. DROP INDEX IF EXISTS public.uq_agent_runtime_request_digest;
  194. ALTER TABLE public.agent_runtime_reservations DROP COLUMN IF EXISTS settlement, DROP COLUMN IF EXISTS settled_at;
  195. ALTER TABLE public.agent_invocation_audits DROP COLUMN IF EXISTS incident_uid, DROP COLUMN IF EXISTS approval_task_uid, DROP COLUMN IF EXISTS request_digest;
  196. """)