20260817_502_tenant_lifecycle_gateway.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. """Add a DB-clock, fenced lifecycle gateway without rewriting migration 501."""
  2. from alembic import op
  3. revision = "20260817_502"
  4. down_revision = "20260817_501"
  5. branch_labels = None
  6. depends_on = None
  7. def upgrade() -> None:
  8. op.execute(
  9. r"""
  10. CREATE FUNCTION public.tenant_lifecycle_transition(p_payload jsonb)
  11. RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_lifecycle$
  12. DECLARE v_tenant text; v_operation text; v_digest text; v_target text; v_fence bigint;
  13. DECLARE v_tenant_row record; v_replay record;
  14. BEGIN
  15. IF jsonb_typeof(p_payload)<>'object'
  16. OR NOT (p_payload ?& ARRAY['tenant_id','operation','expected_fence','idempotency_key','approval_ref'])
  17. OR p_payload-ARRAY['tenant_id','operation','expected_fence','idempotency_key','approval_ref']<>'{}'::jsonb THEN
  18. RAISE EXCEPTION 'tenant_payload_closed';
  19. END IF;
  20. v_tenant:=p_payload->>'tenant_id'; v_operation:=p_payload->>'operation';
  21. IF v_tenant !~ '^[a-z][a-z0-9-]{0,62}$' OR v_operation NOT IN ('freeze','begin_recovery','activate')
  22. OR p_payload->>'idempotency_key' !~ '^[A-Za-z0-9._:-]{1,120}$'
  23. OR p_payload->>'expected_fence' !~ '^[0-9]+$' THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
  24. IF v_operation IN ('begin_recovery','activate') AND coalesce(p_payload->>'approval_ref','') !~ '^[A-Za-z0-9._:-]{1,120}$' THEN
  25. RAISE EXCEPTION 'tenant_recovery_approval_required';
  26. END IF;
  27. v_digest:=encode(sha256(convert_to(p_payload::text,'utf8')),'hex');
  28. PERFORM set_config('dataops.tenant_id',v_tenant,true);
  29. PERFORM pg_advisory_xact_lock(hashtext(v_tenant || '-lifecycle'));
  30. SELECT * INTO v_replay FROM public.tenant_lifecycle_events WHERE tenant_id=v_tenant AND idempotency_key=p_payload->>'idempotency_key' FOR UPDATE;
  31. IF FOUND THEN
  32. IF v_replay.request_digest<>v_digest THEN RAISE EXCEPTION 'tenant_replay_conflict'; END IF;
  33. RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_replay.lease_fence,'state',v_replay.next_state);
  34. END IF;
  35. SELECT * INTO v_tenant_row FROM public.tenants WHERE tenant_id=v_tenant FOR UPDATE;
  36. IF NOT FOUND OR v_tenant_row.lease_fence<>(p_payload->>'expected_fence')::bigint THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
  37. v_target:=CASE
  38. WHEN v_tenant_row.state='active' AND v_operation='freeze' THEN 'frozen'
  39. WHEN v_tenant_row.state='frozen' AND v_operation='begin_recovery' THEN 'recovering'
  40. WHEN v_tenant_row.state='recovering' AND v_operation='activate' THEN 'active'
  41. ELSE NULL END;
  42. IF v_target IS NULL THEN RAISE EXCEPTION 'tenant_transition_denied'; END IF;
  43. UPDATE public.tenants SET state=v_target,lease_fence=lease_fence+1,updated_at=clock_timestamp()
  44. WHERE tenant_id=v_tenant AND lease_fence=v_tenant_row.lease_fence RETURNING lease_fence INTO v_fence;
  45. IF NOT FOUND THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
  46. INSERT INTO public.tenant_lifecycle_events(tenant_id,idempotency_key,request_digest,prior_state,next_state,lease_fence,approval_ref)
  47. VALUES(v_tenant,p_payload->>'idempotency_key',v_digest,v_tenant_row.state,v_target,v_fence,nullif(p_payload->>'approval_ref',''));
  48. INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence)
  49. VALUES(v_tenant,'tenant.lifecycle',v_digest,v_fence);
  50. RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_fence,'state',v_target);
  51. END; $tenant_lifecycle$;
  52. ALTER FUNCTION public.tenant_lifecycle_transition(jsonb) OWNER TO dataops_tenant_foundation_owner;
  53. REVOKE ALL ON FUNCTION public.tenant_lifecycle_transition(jsonb) FROM PUBLIC;
  54. GRANT EXECUTE ON FUNCTION public.tenant_lifecycle_transition(jsonb) TO dataops_app_runtime;
  55. """
  56. )
  57. def downgrade() -> None:
  58. op.execute(
  59. """
  60. REVOKE EXECUTE ON FUNCTION public.tenant_lifecycle_transition(jsonb) FROM dataops_app_runtime;
  61. DROP FUNCTION IF EXISTS public.tenant_lifecycle_transition(jsonb);
  62. """
  63. )