| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899 |
- """Claim-bound, fenced tenant lifecycle and legal-hold boundary."""
- from alembic import op
- revision = "20260817_508"
- down_revision = "20260817_507"
- branch_labels = None
- depends_on = None
- def upgrade() -> None:
- op.execute(
- r"""
- CREATE TABLE public.tenant_legal_holds (
- hold_ref text PRIMARY KEY CHECK (hold_ref ~ '^[A-Za-z0-9._:-]{1,120}$'),
- tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
- state text NOT NULL CHECK (state IN ('active','released')),
- evidence_digest char(64) NOT NULL,
- created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
- released_at timestamptz
- );
- ALTER TABLE public.tenant_legal_holds OWNER TO dataops_tenant_foundation_owner;
- ALTER TABLE public.tenant_legal_holds ENABLE ROW LEVEL SECURITY;
- ALTER TABLE public.tenant_legal_holds FORCE ROW LEVEL SECURITY;
- CREATE POLICY tenant_legal_hold_scope ON public.tenant_legal_holds
- USING (tenant_id=current_setting('dataops.tenant_id',true))
- WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
- REVOKE ALL ON public.tenant_legal_holds FROM PUBLIC,dataops_app_runtime,dataops_tenant_control;
- CREATE FUNCTION public.tenant_control_lifecycle_mutate(p_claim_uid uuid,p_nonce uuid,p_request jsonb)
- RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_lifecycle_control$
- DECLARE v_claim jsonb; v_tenant text; v_digest text; v_action text; v_target text; v_fence bigint;
- DECLARE v_tenant_row record; v_replay record;
- BEGIN
- IF jsonb_typeof(p_request)<>'object' OR NOT (p_request ?& ARRAY['operation','expected_fence','idempotency_key','approval_ref','backup_digest','retention_seconds'])
- OR p_request-ARRAY['operation','expected_fence','idempotency_key','approval_ref','backup_digest','retention_seconds']<>'{}'::jsonb THEN RAISE EXCEPTION 'tenant_payload_closed'; END IF;
- v_action:=p_request->>'operation';
- IF v_action NOT IN ('freeze','begin_recovery','activate','mark_deletion_candidate','delete','rollback')
- OR p_request->>'expected_fence' !~ '^[0-9]{1,18}$'
- OR p_request->>'idempotency_key' !~ '^[A-Za-z0-9._:-]{1,120}$'
- OR jsonb_typeof(p_request->'approval_ref') NOT IN ('string','null')
- OR jsonb_typeof(p_request->'backup_digest') NOT IN ('string','null')
- OR jsonb_typeof(p_request->'retention_seconds') NOT IN ('string','null') THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
- v_claim:=public.tenant_control_consume_claim(p_claim_uid,p_nonce,'lifecycle_transition',p_request);
- v_tenant:=v_claim->>'tenant_id'; v_digest:=v_claim->>'request_digest';
- PERFORM set_config('dataops.tenant_id',v_tenant,true);
- PERFORM pg_advisory_xact_lock(hashtext(v_tenant || '-lifecycle'));
- SELECT * INTO v_replay FROM public.tenant_lifecycle_events WHERE tenant_id=v_tenant AND idempotency_key=p_request->>'idempotency_key' FOR UPDATE;
- IF FOUND THEN
- IF v_replay.request_digest<>v_digest THEN RAISE EXCEPTION 'tenant_replay_conflict'; END IF;
- RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_replay.lease_fence,'state',v_replay.next_state);
- END IF;
- SELECT * INTO v_tenant_row FROM public.tenants WHERE tenant_id=v_tenant FOR UPDATE;
- IF NOT FOUND OR v_tenant_row.lease_fence<>(p_request->>'expected_fence')::bigint THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
- 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;
- IF v_action IN ('mark_deletion_candidate','delete') THEN
- IF coalesce(p_request->>'approval_ref','') !~ '^[A-Za-z0-9._:-]{1,120}$' THEN RAISE EXCEPTION 'tenant_deletion_approval_required'; END IF;
- IF coalesce(p_request->>'backup_digest','') !~ '^[0-9a-f]{64}$' THEN RAISE EXCEPTION 'tenant_backup_required'; END IF;
- IF coalesce(p_request->>'retention_seconds','') !~ '^[1-9][0-9]{0,12}$' THEN RAISE EXCEPTION 'tenant_retention_required'; END IF;
- 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;
- END IF;
- v_target:=CASE
- WHEN v_tenant_row.state='active' AND v_action='freeze' THEN 'frozen'
- WHEN v_tenant_row.state='frozen' AND v_action='begin_recovery' THEN 'recovering'
- WHEN v_tenant_row.state='recovering' AND v_action='activate' THEN 'active'
- WHEN v_tenant_row.state IN ('active','frozen') AND v_action='mark_deletion_candidate' THEN 'deletion_candidate'
- WHEN v_tenant_row.state='deletion_candidate' AND v_action='delete' THEN 'deleted'
- WHEN v_tenant_row.state='deletion_candidate' AND v_action='rollback' THEN 'frozen'
- ELSE NULL END;
- IF v_target IS NULL THEN RAISE EXCEPTION 'tenant_transition_denied'; END IF;
- UPDATE public.tenants SET state=v_target,lease_fence=lease_fence+1,updated_at=clock_timestamp()
- WHERE tenant_id=v_tenant AND lease_fence=v_tenant_row.lease_fence RETURNING lease_fence INTO v_fence;
- IF NOT FOUND THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
- INSERT INTO public.tenant_lifecycle_events(tenant_id,idempotency_key,request_digest,prior_state,next_state,lease_fence,approval_ref,backup_digest,retention_seconds)
- 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);
- INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence) VALUES(v_tenant,'tenant.lifecycle',v_digest,v_fence);
- RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_fence,'state',v_target);
- END; $tenant_lifecycle_control$;
- ALTER FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) OWNER TO dataops_tenant_foundation_owner;
- REVOKE ALL ON FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) FROM PUBLIC,dataops_app_runtime;
- GRANT EXECUTE ON FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) TO dataops_tenant_control;
- """
- )
- def downgrade() -> None:
- op.execute(
- """
- DO $$ BEGIN
- IF EXISTS (SELECT 1 FROM public.tenant_legal_holds LIMIT 1)
- OR EXISTS (SELECT 1 FROM public.tenant_lifecycle_events WHERE next_state IN ('deletion_candidate','deleted')) THEN
- RAISE EXCEPTION 'tenant lifecycle downgrade refused while lifecycle evidence exists';
- END IF;
- END $$;
- REVOKE EXECUTE ON FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb) FROM dataops_tenant_control;
- DROP FUNCTION public.tenant_control_lifecycle_mutate(uuid,uuid,jsonb);
- DROP TABLE public.tenant_legal_holds;
- """
- )
|