20260817_501_tenant_foundation.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. """Create the conditional, database-enforced WP10 tenant foundation."""
  2. from alembic import op
  3. revision = "20260817_501"
  4. down_revision = "20260813_500"
  5. branch_labels = None
  6. depends_on = None
  7. def upgrade() -> None:
  8. op.execute(
  9. r"""
  10. DO $$ BEGIN
  11. IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_tenant_foundation_owner')
  12. OR NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_app_runtime') THEN
  13. RAISE EXCEPTION 'tenant foundation roles must be provisioned before migration';
  14. END IF;
  15. IF NOT pg_has_role(current_user, 'dataops_tenant_foundation_owner', 'MEMBER') THEN
  16. RAISE EXCEPTION 'migration identity lacks tenant foundation owner membership';
  17. END IF;
  18. END $$;
  19. CREATE TABLE public.tenants (
  20. tenant_id text PRIMARY KEY CHECK (tenant_id ~ '^[a-z][a-z0-9-]{0,62}$'),
  21. delivery_mode text NOT NULL CHECK (delivery_mode IN ('private_single_tenant','shared_control_plane','saas')),
  22. state text NOT NULL CHECK (state IN ('provisioning','active','frozen','recovering','deletion_candidate','deleted')),
  23. lease_fence bigint NOT NULL DEFAULT 0 CHECK (lease_fence >= 0),
  24. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  25. updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
  26. );
  27. CREATE TABLE public.tenant_quotas (
  28. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  29. quota_name text NOT NULL CHECK (quota_name IN ('records','storage_objects','background_tasks','events','models','plugins','connectors','api_concurrency')),
  30. limit_units numeric(30,6) NOT NULL CHECK (limit_units > 0),
  31. reserved_units numeric(30,6) NOT NULL DEFAULT 0 CHECK (reserved_units >= 0 AND reserved_units <= limit_units),
  32. lease_fence bigint NOT NULL DEFAULT 0 CHECK (lease_fence >= 0),
  33. updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  34. PRIMARY KEY (tenant_id,quota_name)
  35. );
  36. CREATE TABLE public.tenant_quota_reservations (
  37. reservation_uid uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  38. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  39. quota_name text NOT NULL,
  40. idempotency_key text NOT NULL CHECK (idempotency_key ~ '^[A-Za-z0-9._:-]{1,120}$'),
  41. request_digest char(64) NOT NULL,
  42. amount numeric(30,6) NOT NULL CHECK (amount > 0),
  43. lease_fence bigint NOT NULL CHECK (lease_fence > 0),
  44. status text NOT NULL CHECK (status IN ('reserved','settled','released')),
  45. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  46. updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  47. UNIQUE (tenant_id,quota_name,idempotency_key)
  48. );
  49. CREATE TABLE public.tenant_lifecycle_events (
  50. event_uid uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  51. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  52. idempotency_key text NOT NULL CHECK (idempotency_key ~ '^[A-Za-z0-9._:-]{1,120}$'),
  53. request_digest char(64) NOT NULL,
  54. prior_state text NOT NULL,
  55. next_state text NOT NULL,
  56. lease_fence bigint NOT NULL,
  57. approval_ref text,
  58. backup_digest char(64),
  59. retention_seconds bigint,
  60. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  61. UNIQUE (tenant_id,idempotency_key)
  62. );
  63. CREATE TABLE public.tenant_audit_events (
  64. event_uid uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  65. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  66. event_type text NOT NULL CHECK (event_type ~ '^[a-z][a-z0-9_.-]{0,80}$'),
  67. payload_digest char(64) NOT NULL,
  68. lease_fence bigint NOT NULL DEFAULT 0,
  69. created_at timestamptz NOT NULL DEFAULT clock_timestamp()
  70. );
  71. CREATE TABLE public.tenant_resource_manifests (
  72. manifest_uid uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  73. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  74. resource_type text NOT NULL CHECK (resource_type IN ('object','graph','cache','index','keys','connectors','models','plugins','backup','audit_export')),
  75. namespace text NOT NULL CHECK (namespace ~ '^[a-z][a-z0-9-]{0,62}/[a-z_]+/v1/[a-z0-9][a-z0-9._-]{0,120}$'),
  76. digest char(64) NOT NULL,
  77. provider_enabled boolean NOT NULL DEFAULT false CHECK (provider_enabled=false),
  78. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  79. UNIQUE (tenant_id,resource_type,namespace)
  80. );
  81. ALTER TABLE public.tenants OWNER TO dataops_tenant_foundation_owner;
  82. ALTER TABLE public.tenant_quotas OWNER TO dataops_tenant_foundation_owner;
  83. ALTER TABLE public.tenant_quota_reservations OWNER TO dataops_tenant_foundation_owner;
  84. ALTER TABLE public.tenant_lifecycle_events OWNER TO dataops_tenant_foundation_owner;
  85. ALTER TABLE public.tenant_audit_events OWNER TO dataops_tenant_foundation_owner;
  86. ALTER TABLE public.tenant_resource_manifests OWNER TO dataops_tenant_foundation_owner;
  87. ALTER TABLE public.tenant_quotas ENABLE ROW LEVEL SECURITY;
  88. ALTER TABLE public.tenant_quota_reservations ENABLE ROW LEVEL SECURITY;
  89. ALTER TABLE public.tenant_lifecycle_events ENABLE ROW LEVEL SECURITY;
  90. ALTER TABLE public.tenant_audit_events ENABLE ROW LEVEL SECURITY;
  91. ALTER TABLE public.tenant_resource_manifests ENABLE ROW LEVEL SECURITY;
  92. ALTER TABLE public.tenant_quotas FORCE ROW LEVEL SECURITY;
  93. ALTER TABLE public.tenant_quota_reservations FORCE ROW LEVEL SECURITY;
  94. ALTER TABLE public.tenant_lifecycle_events FORCE ROW LEVEL SECURITY;
  95. ALTER TABLE public.tenant_audit_events FORCE ROW LEVEL SECURITY;
  96. ALTER TABLE public.tenant_resource_manifests FORCE ROW LEVEL SECURITY;
  97. CREATE POLICY tenant_quota_scope ON public.tenant_quotas
  98. USING (tenant_id=current_setting('dataops.tenant_id',true))
  99. WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
  100. CREATE POLICY tenant_quota_reservation_scope ON public.tenant_quota_reservations
  101. USING (tenant_id=current_setting('dataops.tenant_id',true))
  102. WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
  103. CREATE POLICY tenant_lifecycle_scope ON public.tenant_lifecycle_events
  104. USING (tenant_id=current_setting('dataops.tenant_id',true))
  105. WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
  106. CREATE POLICY tenant_audit_scope ON public.tenant_audit_events
  107. USING (tenant_id=current_setting('dataops.tenant_id',true))
  108. WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
  109. CREATE POLICY tenant_manifest_scope ON public.tenant_resource_manifests
  110. USING (tenant_id=current_setting('dataops.tenant_id',true))
  111. WITH CHECK (tenant_id=current_setting('dataops.tenant_id',true));
  112. REVOKE ALL ON TABLE public.tenants,public.tenant_quotas,public.tenant_quota_reservations,
  113. public.tenant_lifecycle_events,public.tenant_audit_events,public.tenant_resource_manifests
  114. FROM PUBLIC,dataops_app_runtime;
  115. GRANT SELECT ON public.tenant_quotas,public.tenant_quota_reservations,public.tenant_lifecycle_events,
  116. public.tenant_audit_events,public.tenant_resource_manifests TO dataops_app_runtime;
  117. CREATE FUNCTION public.tenant_foundation_write(p_action text,p_payload jsonb)
  118. RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_write$
  119. DECLARE v_tenant text; v_digest text; v_amount numeric(30,6); v_quota record; v_reservation record;
  120. DECLARE v_fence bigint; v_mode text;
  121. BEGIN
  122. IF p_action NOT IN ('provision','reserve_quota') OR jsonb_typeof(p_payload)<>'object' THEN
  123. RAISE EXCEPTION 'tenant_payload_closed';
  124. END IF;
  125. IF p_action='provision' THEN
  126. IF NOT (p_payload ?& ARRAY['tenant_id','delivery_mode','idempotency_key'])
  127. OR p_payload-ARRAY['tenant_id','delivery_mode','idempotency_key']<>'{}'::jsonb THEN RAISE EXCEPTION 'tenant_payload_closed'; END IF;
  128. v_tenant:=p_payload->>'tenant_id'; v_mode:=p_payload->>'delivery_mode';
  129. IF v_tenant !~ '^[a-z][a-z0-9-]{0,62}$' OR v_mode NOT IN ('private_single_tenant','shared_control_plane','saas')
  130. OR p_payload->>'idempotency_key' !~ '^[A-Za-z0-9._:-]{1,120}$' THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
  131. PERFORM set_config('dataops.tenant_id',v_tenant,true);
  132. INSERT INTO public.tenants(tenant_id,delivery_mode,state) VALUES(v_tenant,v_mode,'active') ON CONFLICT (tenant_id) DO NOTHING;
  133. INSERT INTO public.tenant_quotas(tenant_id,quota_name,limit_units) VALUES
  134. (v_tenant,'records',1),(v_tenant,'storage_objects',1),(v_tenant,'background_tasks',1),(v_tenant,'events',1),
  135. (v_tenant,'models',1),(v_tenant,'plugins',1),(v_tenant,'connectors',1),(v_tenant,'api_concurrency',1)
  136. ON CONFLICT (tenant_id,quota_name) DO NOTHING;
  137. INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest) VALUES
  138. (v_tenant,'tenant.provision',encode(sha256(convert_to(p_payload::text,'utf8')),'hex'));
  139. RETURN jsonb_build_object('tenant_id',v_tenant,'state','active','lease_fence',0);
  140. END IF;
  141. IF NOT (p_payload ?& ARRAY['tenant_id','quota_name','amount','idempotency_key'])
  142. OR p_payload-ARRAY['tenant_id','quota_name','amount','idempotency_key']<>'{}'::jsonb THEN RAISE EXCEPTION 'tenant_payload_closed'; END IF;
  143. v_tenant:=p_payload->>'tenant_id'; v_digest:=encode(sha256(convert_to(p_payload::text,'utf8')),'hex');
  144. IF v_tenant !~ '^[a-z][a-z0-9-]{0,62}$' OR p_payload->>'quota_name' NOT IN ('records','storage_objects','background_tasks','events','models','plugins','connectors','api_concurrency')
  145. OR p_payload->>'idempotency_key' !~ '^[A-Za-z0-9._:-]{1,120}$' OR p_payload->>'amount' !~ '^[0-9]+(\\.[0-9]{1,6})?$' THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
  146. v_amount:=(p_payload->>'amount')::numeric;
  147. IF v_amount<=0 THEN RAISE EXCEPTION 'tenant_payload_invalid'; END IF;
  148. PERFORM set_config('dataops.tenant_id',v_tenant,true);
  149. PERFORM pg_advisory_xact_lock(hashtext(v_tenant || ':' || (p_payload->>'quota_name')));
  150. SELECT * INTO v_reservation FROM public.tenant_quota_reservations WHERE tenant_id=v_tenant AND quota_name=p_payload->>'quota_name' AND idempotency_key=p_payload->>'idempotency_key' FOR UPDATE;
  151. IF FOUND THEN
  152. IF v_reservation.request_digest<>v_digest THEN RAISE EXCEPTION 'tenant_replay_conflict'; END IF;
  153. RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_reservation.lease_fence,'status',v_reservation.status);
  154. END IF;
  155. SELECT * INTO v_quota FROM public.tenant_quotas WHERE tenant_id=v_tenant AND quota_name=p_payload->>'quota_name' FOR UPDATE;
  156. IF NOT FOUND OR NOT EXISTS(SELECT 1 FROM public.tenants WHERE tenant_id=v_tenant AND state='active') THEN RAISE EXCEPTION 'tenant_not_active'; END IF;
  157. IF v_quota.limit_units-v_quota.reserved_units<v_amount THEN RAISE EXCEPTION 'tenant_quota_exhausted'; END IF;
  158. UPDATE public.tenant_quotas SET reserved_units=reserved_units+v_amount,lease_fence=lease_fence+1,updated_at=clock_timestamp()
  159. WHERE tenant_id=v_tenant AND quota_name=p_payload->>'quota_name' AND lease_fence=v_quota.lease_fence RETURNING lease_fence INTO v_fence;
  160. IF NOT FOUND THEN RAISE EXCEPTION 'tenant_fence_conflict'; END IF;
  161. INSERT INTO public.tenant_quota_reservations(tenant_id,quota_name,idempotency_key,request_digest,amount,lease_fence,status)
  162. VALUES(v_tenant,p_payload->>'quota_name',p_payload->>'idempotency_key',v_digest,v_amount,v_fence,'reserved');
  163. INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence) VALUES(v_tenant,'quota.reserve',v_digest,v_fence);
  164. RETURN jsonb_build_object('tenant_id',v_tenant,'lease_fence',v_fence,'status','reserved');
  165. END; $tenant_write$;
  166. ALTER FUNCTION public.tenant_foundation_write(text,jsonb) OWNER TO dataops_tenant_foundation_owner;
  167. REVOKE ALL ON FUNCTION public.tenant_foundation_write(text,jsonb) FROM PUBLIC;
  168. GRANT EXECUTE ON FUNCTION public.tenant_foundation_write(text,jsonb) TO dataops_app_runtime;
  169. """
  170. )
  171. def downgrade() -> None:
  172. op.execute(
  173. """
  174. REVOKE EXECUTE ON FUNCTION public.tenant_foundation_write(text,jsonb) FROM dataops_app_runtime;
  175. DROP FUNCTION IF EXISTS public.tenant_foundation_write(text,jsonb);
  176. DROP TABLE IF EXISTS public.tenant_resource_manifests;
  177. DROP TABLE IF EXISTS public.tenant_audit_events;
  178. DROP TABLE IF EXISTS public.tenant_lifecycle_events;
  179. DROP TABLE IF EXISTS public.tenant_quota_reservations;
  180. DROP TABLE IF EXISTS public.tenant_quotas;
  181. DROP TABLE IF EXISTS public.tenants;
  182. """
  183. )