20260817_505_tenant_control_claims.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """Persisted tenant membership and one-shot control claims.
  2. This deliberately supersedes the revoked 501--503 payload-authorized paths.
  3. The caller supplies a principal and route only as facts to be checked against
  4. the persistent mapping; a tenant identifier is never accepted by these
  5. functions. A claim is single-use, DB-clock bounded, and binds the exact
  6. JSON request that will later be authorized.
  7. """
  8. from alembic import op
  9. revision = "20260817_505"
  10. down_revision = "20260817_504"
  11. branch_labels = None
  12. depends_on = None
  13. def upgrade() -> None:
  14. op.execute(
  15. r"""
  16. DO $$ BEGIN
  17. IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_tenant_foundation_owner')
  18. OR NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_tenant_control') THEN
  19. RAISE EXCEPTION 'tenant control roles must be provisioned before migration';
  20. END IF;
  21. IF NOT pg_has_role(current_user, 'dataops_tenant_foundation_owner', 'MEMBER')
  22. OR NOT pg_has_role(current_user, 'dataops_tenant_control', 'MEMBER') THEN
  23. RAISE EXCEPTION 'migration identity lacks tenant control memberships';
  24. END IF;
  25. END $$;
  26. CREATE TABLE public.tenant_control_memberships (
  27. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  28. principal_id uuid NOT NULL,
  29. host text NOT NULL CHECK (host=lower(host) AND host ~ '^[a-z0-9][a-z0-9.-]{0,252}$'),
  30. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  31. PRIMARY KEY (tenant_id,principal_id,host),
  32. UNIQUE (principal_id,host)
  33. );
  34. CREATE TABLE public.tenant_control_claims (
  35. claim_uid uuid PRIMARY KEY,
  36. nonce uuid NOT NULL UNIQUE,
  37. tenant_id text NOT NULL REFERENCES public.tenants(tenant_id) ON DELETE RESTRICT,
  38. principal_id uuid NOT NULL,
  39. host text NOT NULL CHECK (host=lower(host) AND host ~ '^[a-z0-9][a-z0-9.-]{0,252}$'),
  40. action text NOT NULL CHECK (action IN ('quota_reserve','lifecycle_transition')),
  41. request jsonb NOT NULL CHECK (jsonb_typeof(request)='object'),
  42. request_digest char(64) NOT NULL,
  43. status text NOT NULL DEFAULT 'issued' CHECK (status IN ('issued','consumed')),
  44. created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  45. expires_at timestamptz NOT NULL,
  46. consumed_at timestamptz,
  47. CHECK (expires_at > created_at),
  48. CHECK ((status='issued' AND consumed_at IS NULL) OR (status='consumed' AND consumed_at IS NOT NULL))
  49. );
  50. CREATE INDEX tenant_control_claims_active_idx
  51. ON public.tenant_control_claims(claim_uid,status,expires_at);
  52. ALTER TABLE public.tenant_control_memberships OWNER TO dataops_tenant_foundation_owner;
  53. ALTER TABLE public.tenant_control_claims OWNER TO dataops_tenant_foundation_owner;
  54. ALTER TABLE public.tenants ENABLE ROW LEVEL SECURITY;
  55. REVOKE ALL ON TABLE public.tenants,public.tenant_control_memberships,public.tenant_control_claims
  56. FROM PUBLIC,dataops_app_runtime,dataops_tenant_control;
  57. CREATE FUNCTION public.tenant_control_issue_claim(p_payload jsonb)
  58. RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_claim_issue$
  59. DECLARE v_principal uuid; v_host text; v_action text; v_tenant text; v_claim uuid; v_nonce uuid;
  60. DECLARE v_request jsonb; v_digest text; v_expires timestamptz;
  61. BEGIN
  62. IF NOT pg_has_role(session_user,'dataops_tenant_control','MEMBER') THEN
  63. RAISE EXCEPTION 'tenant_control_identity_required';
  64. END IF;
  65. IF jsonb_typeof(p_payload)<>'object'
  66. OR NOT (p_payload ?& ARRAY['claim_uid','nonce','principal_id','host','action','request'])
  67. OR p_payload-ARRAY['claim_uid','nonce','principal_id','host','action','request']<>'{}'::jsonb THEN
  68. RAISE EXCEPTION 'tenant_payload_closed';
  69. END IF;
  70. 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}$'
  71. 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}$'
  72. 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}$'
  73. OR p_payload->>'host' !~ '^[a-z0-9][a-z0-9.-]{0,252}$'
  74. OR p_payload->>'host' <> lower(p_payload->>'host')
  75. OR p_payload->>'action' NOT IN ('quota_reserve','lifecycle_transition')
  76. OR jsonb_typeof(p_payload->'request')<>'object' THEN
  77. RAISE EXCEPTION 'tenant_payload_invalid';
  78. END IF;
  79. v_claim := (p_payload->>'claim_uid')::uuid;
  80. v_nonce := (p_payload->>'nonce')::uuid;
  81. v_principal := (p_payload->>'principal_id')::uuid;
  82. v_host := p_payload->>'host';
  83. v_action := p_payload->>'action';
  84. v_request := p_payload->'request';
  85. SELECT m.tenant_id INTO v_tenant
  86. FROM public.tenant_control_memberships m
  87. JOIN public.tenants t ON t.tenant_id=m.tenant_id
  88. WHERE m.principal_id=v_principal AND m.host=v_host AND t.state='active';
  89. IF NOT FOUND THEN RAISE EXCEPTION 'tenant_membership_denied'; END IF;
  90. v_digest := encode(sha256(convert_to(v_request::text,'utf8')),'hex');
  91. v_expires := clock_timestamp()+interval '120 seconds';
  92. INSERT INTO public.tenant_control_claims(
  93. claim_uid,nonce,tenant_id,principal_id,host,action,request,request_digest,expires_at
  94. ) VALUES(v_claim,v_nonce,v_tenant,v_principal,v_host,v_action,v_request,v_digest,v_expires);
  95. PERFORM set_config('dataops.tenant_id',v_tenant,true);
  96. INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence)
  97. VALUES(v_tenant,'tenant.claim.issued',v_digest,0);
  98. RETURN jsonb_build_object('claim_uid',v_claim,'tenant_id',v_tenant,'action',v_action,
  99. 'request_digest',v_digest,'expires_at',v_expires);
  100. END; $tenant_claim_issue$;
  101. ALTER FUNCTION public.tenant_control_issue_claim(jsonb) OWNER TO dataops_tenant_foundation_owner;
  102. CREATE FUNCTION public.tenant_control_consume_claim(
  103. p_claim_uid uuid,p_nonce uuid,p_action text,p_request jsonb
  104. ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,public AS $tenant_claim_consume$
  105. DECLARE v_claim public.tenant_control_claims%ROWTYPE;
  106. BEGIN
  107. IF NOT pg_has_role(session_user,'dataops_tenant_control','MEMBER') THEN
  108. RAISE EXCEPTION 'tenant_control_identity_required';
  109. END IF;
  110. IF p_action NOT IN ('quota_reserve','lifecycle_transition') OR jsonb_typeof(p_request)<>'object' THEN
  111. RAISE EXCEPTION 'tenant_payload_closed';
  112. END IF;
  113. SELECT * INTO v_claim FROM public.tenant_control_claims
  114. WHERE claim_uid=p_claim_uid FOR UPDATE;
  115. IF NOT FOUND OR v_claim.nonce<>p_nonce OR v_claim.action<>p_action
  116. OR v_claim.request<>p_request THEN
  117. RAISE EXCEPTION 'tenant_claim_denied';
  118. END IF;
  119. IF v_claim.status<>'issued' OR v_claim.expires_at<=clock_timestamp() THEN
  120. RAISE EXCEPTION 'tenant_claim_replayed_or_expired';
  121. END IF;
  122. UPDATE public.tenant_control_claims SET status='consumed',consumed_at=clock_timestamp()
  123. WHERE claim_uid=v_claim.claim_uid AND status='issued';
  124. IF NOT FOUND THEN RAISE EXCEPTION 'tenant_claim_replayed_or_expired'; END IF;
  125. PERFORM set_config('dataops.tenant_id',v_claim.tenant_id,true);
  126. INSERT INTO public.tenant_audit_events(tenant_id,event_type,payload_digest,lease_fence)
  127. VALUES(v_claim.tenant_id,'tenant.claim.consumed',v_claim.request_digest,0);
  128. RETURN jsonb_build_object('claim_uid',v_claim.claim_uid,'tenant_id',v_claim.tenant_id,
  129. 'principal_id',v_claim.principal_id,'action',v_claim.action,
  130. 'request_digest',v_claim.request_digest);
  131. END; $tenant_claim_consume$;
  132. ALTER FUNCTION public.tenant_control_consume_claim(uuid,uuid,text,jsonb) OWNER TO dataops_tenant_foundation_owner;
  133. REVOKE ALL ON FUNCTION public.tenant_control_issue_claim(jsonb),
  134. public.tenant_control_consume_claim(uuid,uuid,text,jsonb) FROM PUBLIC,dataops_app_runtime;
  135. GRANT EXECUTE ON FUNCTION public.tenant_control_issue_claim(jsonb),
  136. public.tenant_control_consume_claim(uuid,uuid,text,jsonb) TO dataops_tenant_control;
  137. """
  138. )
  139. def downgrade() -> None:
  140. op.execute(
  141. """
  142. DO $$ BEGIN
  143. IF EXISTS (SELECT 1 FROM public.tenant_control_claims LIMIT 1)
  144. OR EXISTS (SELECT 1 FROM public.tenant_control_memberships LIMIT 1) THEN
  145. RAISE EXCEPTION 'tenant control downgrade refused while control data exists';
  146. END IF;
  147. END $$;
  148. REVOKE EXECUTE ON FUNCTION public.tenant_control_issue_claim(jsonb),
  149. public.tenant_control_consume_claim(uuid,uuid,text,jsonb) FROM dataops_tenant_control;
  150. DROP FUNCTION public.tenant_control_consume_claim(uuid,uuid,text,jsonb);
  151. DROP FUNCTION public.tenant_control_issue_claim(jsonb);
  152. DROP TABLE public.tenant_control_claims;
  153. DROP TABLE public.tenant_control_memberships;
  154. """
  155. )