20260817_509_lifecycle_claim_state_gate.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839
  1. """Permit claim-bound lifecycle recovery while retaining active-only quota claims."""
  2. from alembic import op
  3. revision = "20260817_509"
  4. down_revision = "20260817_508"
  5. branch_labels = None
  6. depends_on = None
  7. def upgrade() -> None:
  8. op.execute(r"""
  9. CREATE OR REPLACE FUNCTION public.tenant_control_issue_claim(p_payload jsonb)
  10. RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_claim_issue$
  11. DECLARE v_principal uuid; v_host text; v_action text; v_tenant text; v_claim uuid; v_nonce uuid; v_request jsonb; v_digest text; v_expires timestamptz;
  12. BEGIN
  13. IF NOT pg_has_role(session_user,'dataops_tenant_control','MEMBER') THEN RAISE EXCEPTION 'tenant_control_identity_required'; END IF;
  14. IF jsonb_typeof(p_payload)<>'object' OR NOT (p_payload ?& ARRAY['claim_uid','nonce','principal_id','host','action','request']) OR p_payload-ARRAY['claim_uid','nonce','principal_id','host','action','request']<>'{}'::jsonb THEN RAISE EXCEPTION 'tenant_payload_closed'; END IF;
  15. IF coalesce(p_payload->>'claim_uid','') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' OR coalesce(p_payload->>'nonce','') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' OR coalesce(p_payload->>'principal_id','') !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' OR p_payload->>'host' !~ '^[a-z0-9][a-z0-9.-]{0,252}$' OR p_payload->>'host'<>lower(p_payload->>'host') OR p_payload->>'action' NOT IN ('quota_reserve','quota_settle','quota_release','lifecycle_transition') OR jsonb_typeof(p_payload->'request')<>'object' THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
  16. v_claim:=(p_payload->>'claim_uid')::uuid; v_nonce:=(p_payload->>'nonce')::uuid; v_principal:=(p_payload->>'principal_id')::uuid; v_host:=p_payload->>'host'; v_action:=p_payload->>'action'; v_request:=p_payload->'request';
  17. SELECT m.tenant_id INTO v_tenant FROM public.tenant_control_memberships m JOIN public.tenants t ON t.tenant_id=m.tenant_id WHERE m.principal_id=v_principal AND m.host=v_host AND t.delivery_mode='private_single_tenant' AND ((v_action IN ('quota_reserve','quota_settle','quota_release') AND t.state='active') OR (v_action='lifecycle_transition' AND t.state<>'deleted'));
  18. IF NOT FOUND THEN RAISE EXCEPTION 'tenant_membership_denied'; END IF;
  19. v_digest:=encode(sha256(convert_to(v_request::text,'utf8')),'hex'); v_expires:=clock_timestamp()+interval '120 seconds';
  20. INSERT INTO public.tenant_control_claims(claim_uid,nonce,tenant_id,principal_id,host,action,request,request_digest,expires_at) VALUES(v_claim,v_nonce,v_tenant,v_principal,v_host,v_action,v_request,v_digest,v_expires);
  21. PERFORM set_config('dataops.tenant_id',v_tenant,true); INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence) VALUES(v_tenant,'tenant.claim.issued',v_digest,0);
  22. RETURN jsonb_build_object('claim_uid',v_claim,'tenant_id',v_tenant,'action',v_action,'request_digest',v_digest,'expires_at',v_expires);
  23. END; $tenant_claim_issue$;
  24. ALTER FUNCTION public.tenant_control_issue_claim(jsonb) OWNER TO dataops_tenant_foundation_owner;
  25. REVOKE ALL ON FUNCTION public.tenant_control_issue_claim(jsonb) FROM PUBLIC,dataops_app_runtime;
  26. GRANT EXECUTE ON FUNCTION public.tenant_control_issue_claim(jsonb) TO dataops_tenant_control;
  27. """)
  28. def downgrade() -> None:
  29. op.execute("""
  30. DO $$ BEGIN
  31. IF EXISTS (SELECT 1 FROM public.tenant_control_claims c JOIN public.tenants t ON t.tenant_id=c.tenant_id WHERE c.action='lifecycle_transition' AND t.state<>'active') THEN RAISE EXCEPTION 'lifecycle claim state-gate downgrade refused while non-active claims exist'; END IF;
  32. END $$;
  33. """)