20260817_508_tenant_controlled_lifecycle.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. """Claim-bound, fenced tenant lifecycle and legal-hold boundary."""
  2. from alembic import op
  3. revision = "20260817_508"
  4. down_revision = "20260817_507"
  5. branch_labels = None
  6. depends_on = None
  7. def upgrade() -> None:
  8. op.execute(
  9. r"""
  10. CREATE TABLE public.tenant_legal_holds (
  11. hold_ref text PRIMARY KEY CHECK (hold_ref ~ '^[A-Za-z0-9._:-]{1,120}$'),
  12. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  13. state text NOT NULL CHECK (state IN ('active','released')),
  14. evidence_digest char(64) NOT NULL,
  15. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  16. released_at timestamptz
  17. );
  18. ALTER TABLE public.tenant_legal_holds OWNER TO dataops_tenant_foundation_owner;
  19. ALTER TABLE public.tenant_legal_holds ENABLE ROW LEVEL SECURITY;
  20. ALTER TABLE public.tenant_legal_holds FORCE ROW LEVEL SECURITY;
  21. CREATE POLICY tenant_legal_hold_scope ON public.tenant_legal_holds
  22. USING (tenant_id=current_setting('dataops.tenant_id',true))
  23. WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
  24. REVOKE ALL ON public.tenant_legal_holds FROM PUBLIC,dataops_app_runtime,dataops_tenant_control;
  25. CREATE FUNCTION public.tenant_control_lifecycle_mutate(p_claim_uid uuid,p_nonce uuid,p_request jsonb)
  26. RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_lifecycle_control$
  27. DECLARE v_claim jsonb; v_tenant text; v_digest text; v_action text; v_target text; v_fence bigint;
  28. DECLARE v_tenant_row record; v_replay record;
  29. BEGIN
  30. IF jsonb_typeof(p_request)<>'object' OR NOT (p_request ?& ARRAY['operation','expected_fence','idempotency_key','approval_ref','backup_digest','retention_seconds'])
  31. OR p_request-ARRAY['operation','expected_fence','idempotency_key','approval_ref','backup_digest','retention_seconds']<>'{}'::jsonb THEN RAISE EXCEPTION 'tenant_payload_closed'; END IF;
  32. v_action:=p_request->>'operation';
  33. IF v_action NOT IN ('freeze','begin_recovery','activate','mark_deletion_candidate','delete','rollback')
  34. OR p_request->>'expected_fence' !~ '^[0-9]{1,18}$'
  35. OR p_request->>'idempotency_key' !~ '^[A-Za-z0-9._:-]{1,120}$'
  36. OR jsonb_typeof(p_request->'approval_ref') NOT IN ('string','null')
  37. OR jsonb_typeof(p_request->'backup_digest') NOT IN ('string','null')
  38. OR jsonb_typeof(p_request->'retention_seconds') NOT IN ('string','null') THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
  39. v_claim:=public.tenant_control_consume_claim(p_claim_uid,p_nonce,'lifecycle_transition',p_request);
  40. v_tenant:=v_claim->>'tenant_id'; v_digest:=v_claim->>'request_digest';
  41. PERFORM set_config('dataops.tenant_id',v_tenant,true);
  42. PERFORM pg_advisory_xact_lock(hashtext(v_tenant || '-lifecycle'));
  43. SELECT * INTO v_replay FROM public.tenant_lifecycle_events WHERE tenant_id=v_tenant AND idempotency_key=p_request->>'idempotency_key' FOR UPDATE;
  44. IF FOUND THEN
  45. IF v_replay.request_digest<>v_digest THEN RAISE EXCEPTION 'tenant_replay_conflict'; END IF;
  46. RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_replay.lease_fence,'state',v_replay.next_state);
  47. END IF;
  48. SELECT * INTO v_tenant_row FROM public.tenants WHERE tenant_id=v_tenant FOR UPDATE;
  49. IF NOT FOUND OR v_tenant_row.lease_fence<>(p_request->>'expected_fence')::bigint THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
  50. IF v_action IN ('begin_recovery','activate') AND coalesce(p_request->>'approval_ref','') !~ '^[A-Za-z0-9._:-]{1,120}$' THEN RAISE EXCEPTION 'tenant_recovery_approval_required'; END IF;
  51. IF v_action IN ('mark_deletion_candidate','delete') THEN
  52. IF coalesce(p_request->>'approval_ref','') !~ '^[A-Za-z0-9._:-]{1,120}$' THEN RAISE EXCEPTION 'tenant_deletion_approval_required'; END IF;
  53. IF coalesce(p_request->>'backup_digest','') !~ '^[0-9a-f]{64}$' THEN RAISE EXCEPTION 'tenant_backup_required'; END IF;
  54. IF coalesce(p_request->>'retention_seconds','') !~ '^[1-9][0-9]{0,12}$' THEN RAISE EXCEPTION 'tenant_retention_required'; END IF;
  55. IF EXISTS(SELECT 1 FROM public.tenant_legal_holds WHERE tenant_id=v_tenant AND state='active') THEN RAISE EXCEPTION 'tenant_hold_active'; END IF;
  56. END IF;
  57. v_target:=CASE
  58. WHEN v_tenant_row.state='active' AND v_action='freeze' THEN 'frozen'
  59. WHEN v_tenant_row.state='frozen' AND v_action='begin_recovery' THEN 'recovering'
  60. WHEN v_tenant_row.state='recovering' AND v_action='activate' THEN 'active'
  61. WHEN v_tenant_row.state IN ('active','frozen') AND v_action='mark_deletion_candidate' THEN 'deletion_candidate'
  62. WHEN v_tenant_row.state='deletion_candidate' AND v_action='delete' THEN 'deleted'
  63. WHEN v_tenant_row.state='deletion_candidate' AND v_action='rollback' THEN 'frozen'
  64. ELSE NULL END;
  65. IF v_target IS NULL THEN RAISE EXCEPTION 'tenant_transition_denied'; END IF;
  66. UPDATE public.tenants SET state=v_target,lease_fence=lease_fence+1,updated_at=clock_timestamp()
  67. WHERE tenant_id=v_tenant AND lease_fence=v_tenant_row.lease_fence RETURNING lease_fence INTO v_fence;
  68. IF NOT FOUND THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
  69. INSERT INTO public.tenant_lifecycle_events(tenant_id,idempotency_key,request_digest,prior_state,next_state,lease_fence,approval_ref,backup_digest,retention_seconds)
  70. VALUES(v_tenant,p_request->>'idempotency_key',v_digest,v_tenant_row.state,v_target,v_fence,nullif(p_request->>'approval_ref',''),nullif(p_request->>'backup_digest',''),nullif(p_request->>'retention_seconds','')::bigint);
  71. INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence) VALUES(v_tenant,'tenant.lifecycle',v_digest,v_fence);
  72. RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_fence,'state',v_target);
  73. END; $tenant_lifecycle_control$;
  74. ALTER FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) OWNER TO dataops_tenant_foundation_owner;
  75. REVOKE ALL ON FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) FROM PUBLIC,dataops_app_runtime;
  76. GRANT EXECUTE ON FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) TO dataops_tenant_control;
  77. """
  78. )
  79. def downgrade() -> None:
  80. op.execute(
  81. """
  82. DO $$ BEGIN
  83. IF EXISTS (SELECT 1 FROM public.tenant_legal_holds LIMIT 1)
  84. OR EXISTS (SELECT 1 FROM public.tenant_lifecycle_events WHERE next_state IN ('deletion_candidate','deleted')) THEN
  85. RAISE EXCEPTION 'tenant lifecycle downgrade refused while lifecycle evidence exists';
  86. END IF;
  87. END $$;
  88. REVOKE EXECUTE ON FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) FROM dataops_tenant_control;
  89. DROP FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb);
  90. DROP TABLE public.tenant_legal_holds;
  91. """
  92. )