20260809_477_edge_gateway_control_plane.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. """Enterprise edge-gateway control-plane identity and durable ledgers."""
  2. from alembic import op
  3. revision = "20260809_477"
  4. down_revision = "20260802_476"
  5. branch_labels = None
  6. depends_on = None
  7. def upgrade() -> None:
  8. op.execute(r"""
  9. DO $$
  10. BEGIN
  11. IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_app_runtime') OR
  12. NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dataops_edge_evidence_owner') THEN
  13. RAISE EXCEPTION 'edge database roles are not pre-provisioned; run the privileged role-init step';
  14. END IF;
  15. IF EXISTS (
  16. SELECT 1 FROM pg_roles
  17. WHERE rolname IN ('dataops_app_runtime','dataops_edge_evidence_owner')
  18. AND (rolcanlogin OR rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication)
  19. ) THEN
  20. RAISE EXCEPTION 'edge database roles violate the NOLOGIN least-privilege contract';
  21. END IF;
  22. END $$;
  23. CREATE OR REPLACE FUNCTION public.edge_hosts_valid(hosts TEXT[], require_nonempty BOOLEAN)
  24. RETURNS BOOLEAN LANGUAGE plpgsql IMMUTABLE STRICT AS $$
  25. DECLARE host TEXT;
  26. BEGIN
  27. IF cardinality(hosts)>32 OR (require_nonempty AND cardinality(hosts)=0) OR
  28. cardinality(hosts)<>(SELECT count(DISTINCT item) FROM unnest(hosts) item) THEN
  29. RETURN FALSE;
  30. END IF;
  31. FOREACH host IN ARRAY hosts LOOP
  32. IF host<>lower(host) OR host !~ '^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$' OR
  33. length(host)>253 OR host LIKE '%..%' OR host LIKE '%:%' OR
  34. host LIKE '%/%' OR host LIKE '%@%' THEN
  35. RETURN FALSE;
  36. END IF;
  37. END LOOP;
  38. RETURN TRUE;
  39. END $$;
  40. CREATE TABLE public.edge_gateway_enrollments (
  41. uid UUID PRIMARY KEY, gateway_id UUID NOT NULL UNIQUE,
  42. gateway_name VARCHAR(200) NOT NULL, environment VARCHAR(20) NOT NULL,
  43. network_zone VARCHAR(120) NOT NULL, policy_digest CHAR(64) NOT NULL,
  44. expected_certificate_sha256 CHAR(64) NOT NULL,
  45. allowed_control_hosts TEXT[] NOT NULL, allowed_proxy_hosts TEXT[] NOT NULL,
  46. token_hash CHAR(64) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL DEFAULT 'pending',
  47. expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ,
  48. created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  49. CONSTRAINT ck_edge_enrollment_environment CHECK (environment IN ('development','staging','production')),
  50. CONSTRAINT ck_edge_enrollment_status CHECK (status IN ('pending','consumed','revoked','expired')),
  51. CONSTRAINT ck_edge_enrollment_digest CHECK (
  52. policy_digest ~ '^[0-9a-f]{64}$' AND token_hash ~ '^[0-9a-f]{64}$' AND
  53. expected_certificate_sha256 ~ '^[0-9a-f]{64}$'),
  54. CONSTRAINT ck_edge_enrollment_ttl CHECK (expires_at > created_at AND expires_at <= created_at + INTERVAL '24 hours'),
  55. CONSTRAINT ck_edge_enrollment_consumed CHECK ((status='consumed') = (consumed_at IS NOT NULL)),
  56. CONSTRAINT ck_edge_enrollment_hosts CHECK (
  57. public.edge_hosts_valid(allowed_control_hosts,TRUE) AND
  58. public.edge_hosts_valid(allowed_proxy_hosts,FALSE))
  59. );
  60. CREATE INDEX ix_edge_enrollment_pending ON public.edge_gateway_enrollments(token_hash,expires_at) WHERE status='pending';
  61. CREATE OR REPLACE FUNCTION public.protect_edge_enrollment_identity()
  62. RETURNS trigger LANGUAGE plpgsql AS $$
  63. BEGIN
  64. IF NEW.uid<>OLD.uid OR NEW.gateway_id<>OLD.gateway_id OR
  65. NEW.gateway_name<>OLD.gateway_name OR NEW.environment<>OLD.environment OR
  66. NEW.network_zone<>OLD.network_zone OR NEW.policy_digest<>OLD.policy_digest OR
  67. NEW.expected_certificate_sha256<>OLD.expected_certificate_sha256 OR
  68. NEW.allowed_control_hosts<>OLD.allowed_control_hosts OR
  69. NEW.allowed_proxy_hosts<>OLD.allowed_proxy_hosts OR NEW.token_hash<>OLD.token_hash OR
  70. NEW.expires_at<>OLD.expires_at OR NEW.created_by<>OLD.created_by OR
  71. NEW.created_at<>OLD.created_at THEN
  72. RAISE EXCEPTION 'edge enrollment identity binding is immutable';
  73. END IF;
  74. IF NOT (
  75. (OLD.status='pending' AND NEW.status IN ('pending','consumed','revoked','expired')) OR
  76. (OLD.status<>'pending' AND NEW.status=OLD.status)
  77. ) THEN
  78. RAISE EXCEPTION 'edge enrollment status transition is invalid';
  79. END IF;
  80. RETURN NEW;
  81. END $$;
  82. CREATE TRIGGER trg_edge_enrollment_identity_immutable
  83. BEFORE UPDATE ON public.edge_gateway_enrollments FOR EACH ROW
  84. EXECUTE FUNCTION public.protect_edge_enrollment_identity();
  85. CREATE TABLE public.edge_gateways (
  86. uid UUID PRIMARY KEY, name VARCHAR(200) NOT NULL, environment VARCHAR(20) NOT NULL,
  87. network_zone VARCHAR(120) NOT NULL, status VARCHAR(20) NOT NULL,
  88. current_generation INTEGER NOT NULL, policy_digest CHAR(64) NOT NULL,
  89. allowed_control_hosts TEXT[] NOT NULL, allowed_proxy_hosts TEXT[] NOT NULL,
  90. last_heartbeat_at TIMESTAMPTZ, runtime_version VARCHAR(80),
  91. safe_health_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
  92. created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  93. updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at TIMESTAMPTZ,
  94. CONSTRAINT ck_edge_gateway_environment CHECK (environment IN ('development','staging','production')),
  95. CONSTRAINT ck_edge_gateway_status CHECK (status IN ('pending','active','offline','revoked')),
  96. CONSTRAINT ck_edge_gateway_generation CHECK (current_generation > 0),
  97. CONSTRAINT ck_edge_gateway_policy CHECK (policy_digest ~ '^[0-9a-f]{64}$'),
  98. CONSTRAINT ck_edge_gateway_summary CHECK (jsonb_typeof(safe_health_summary)='object'),
  99. CONSTRAINT ck_edge_gateway_hosts CHECK (
  100. public.edge_hosts_valid(allowed_control_hosts,TRUE) AND
  101. public.edge_hosts_valid(allowed_proxy_hosts,FALSE)),
  102. CONSTRAINT fk_edge_gateway_enrollment FOREIGN KEY (uid)
  103. REFERENCES public.edge_gateway_enrollments(gateway_id)
  104. );
  105. CREATE INDEX ix_edge_gateways_environment_status ON public.edge_gateways(environment,status);
  106. CREATE TABLE public.edge_gateway_credentials (
  107. uid UUID PRIMARY KEY, gateway_id UUID NOT NULL REFERENCES public.edge_gateways(uid),
  108. generation INTEGER NOT NULL, credential_hash CHAR(64) NOT NULL UNIQUE,
  109. certificate_sha256 CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL,
  110. expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  111. revoked_at TIMESTAMPTZ, rotated_from_uid UUID REFERENCES public.edge_gateway_credentials(uid),
  112. CONSTRAINT uq_edge_credential_generation UNIQUE(gateway_id,generation),
  113. CONSTRAINT ck_edge_credential_status CHECK (status IN ('active','rotated','revoked','expired')),
  114. CONSTRAINT ck_edge_credential_generation CHECK (generation > 0),
  115. CONSTRAINT ck_edge_credential_hashes CHECK (credential_hash ~ '^[0-9a-f]{64}$' AND certificate_sha256 ~ '^[0-9a-f]{64}$'),
  116. CONSTRAINT ck_edge_credential_expiry CHECK (expires_at > created_at AND expires_at <= created_at + INTERVAL '370 days')
  117. );
  118. CREATE INDEX ix_edge_credential_auth ON public.edge_gateway_credentials(credential_hash,status,expires_at);
  119. CREATE UNIQUE INDEX uq_edge_one_active_credential
  120. ON public.edge_gateway_credentials(gateway_id) WHERE status='active';
  121. CREATE TABLE public.edge_gateway_rotation_requests (
  122. gateway_id UUID NOT NULL REFERENCES public.edge_gateways(uid),
  123. request_id VARCHAR(255) NOT NULL, request_digest CHAR(64) NOT NULL,
  124. generation INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  125. PRIMARY KEY(gateway_id,request_id),
  126. CONSTRAINT ck_edge_rotation_digest CHECK (request_digest ~ '^[0-9a-f]{64}$')
  127. );
  128. CREATE OR REPLACE FUNCTION public.protect_edge_gateway_identity()
  129. RETURNS trigger LANGUAGE plpgsql AS $$
  130. BEGIN
  131. IF NEW.uid<>OLD.uid OR NEW.name<>OLD.name OR NEW.environment<>OLD.environment OR
  132. NEW.network_zone<>OLD.network_zone OR NEW.policy_digest<>OLD.policy_digest OR
  133. NEW.allowed_control_hosts<>OLD.allowed_control_hosts OR
  134. NEW.allowed_proxy_hosts<>OLD.allowed_proxy_hosts THEN
  135. RAISE EXCEPTION 'registered edge gateway identity binding is immutable';
  136. END IF;
  137. IF OLD.status='revoked' AND NEW.status<>'revoked' THEN
  138. RAISE EXCEPTION 'revoked edge gateway is terminal';
  139. END IF;
  140. IF NEW.current_generation<OLD.current_generation OR
  141. NEW.current_generation>OLD.current_generation+1 THEN
  142. RAISE EXCEPTION 'edge gateway generation transition is invalid';
  143. END IF;
  144. IF NOT (
  145. (OLD.status='pending' AND NEW.status IN ('pending','active','revoked')) OR
  146. (OLD.status='active' AND NEW.status IN ('active','offline','revoked')) OR
  147. (OLD.status='offline' AND NEW.status IN ('active','offline','revoked')) OR
  148. (OLD.status='revoked' AND NEW.status='revoked')
  149. ) THEN
  150. RAISE EXCEPTION 'edge gateway status transition is invalid';
  151. END IF;
  152. RETURN NEW;
  153. END $$;
  154. CREATE TRIGGER trg_edge_gateway_identity_immutable
  155. BEFORE UPDATE ON public.edge_gateways FOR EACH ROW
  156. EXECUTE FUNCTION public.protect_edge_gateway_identity();
  157. CREATE OR REPLACE FUNCTION public.protect_edge_credential_identity()
  158. RETURNS trigger LANGUAGE plpgsql AS $$
  159. BEGIN
  160. IF NEW.uid<>OLD.uid OR NEW.gateway_id<>OLD.gateway_id OR
  161. NEW.generation<>OLD.generation OR NEW.credential_hash<>OLD.credential_hash OR
  162. NEW.certificate_sha256<>OLD.certificate_sha256 OR
  163. NEW.created_at<>OLD.created_at OR NEW.expires_at<>OLD.expires_at THEN
  164. RAISE EXCEPTION 'edge credential identity binding is immutable';
  165. END IF;
  166. IF NOT (
  167. (OLD.status='active' AND NEW.status IN ('active','rotated','revoked','expired')) OR
  168. (OLD.status<>'active' AND NEW.status=OLD.status)
  169. ) THEN
  170. RAISE EXCEPTION 'edge credential status transition is invalid';
  171. END IF;
  172. RETURN NEW;
  173. END $$;
  174. CREATE TRIGGER trg_edge_credential_identity_immutable
  175. BEFORE UPDATE ON public.edge_gateway_credentials FOR EACH ROW
  176. EXECUTE FUNCTION public.protect_edge_credential_identity();
  177. CREATE OR REPLACE FUNCTION public.validate_edge_gateway_binding(p_gateway UUID)
  178. RETURNS void LANGUAGE plpgsql AS $$
  179. DECLARE g public.edge_gateways%ROWTYPE;
  180. BEGIN
  181. SELECT * INTO g FROM public.edge_gateways WHERE uid=p_gateway;
  182. IF NOT FOUND OR g.status='revoked' THEN RETURN; END IF;
  183. IF g.status IN ('active','offline') AND NOT EXISTS (
  184. SELECT 1 FROM public.edge_gateway_credentials c
  185. WHERE c.gateway_id=g.uid AND c.generation=g.current_generation
  186. AND c.status='active' AND c.expires_at>CURRENT_TIMESTAMP
  187. ) THEN
  188. RAISE EXCEPTION 'active edge gateway requires its exact active credential binding';
  189. END IF;
  190. IF g.status IN ('active','offline') AND NOT EXISTS (
  191. SELECT 1 FROM public.edge_gateway_enrollments e
  192. JOIN public.edge_gateway_credentials c ON c.gateway_id=g.uid AND c.generation=1
  193. WHERE e.gateway_id=g.uid AND e.status='consumed'
  194. AND e.gateway_name=g.name AND e.environment=g.environment
  195. AND e.network_zone=g.network_zone AND e.policy_digest=g.policy_digest
  196. AND e.allowed_control_hosts=g.allowed_control_hosts
  197. AND e.allowed_proxy_hosts=g.allowed_proxy_hosts
  198. AND e.expected_certificate_sha256=c.certificate_sha256
  199. ) THEN
  200. RAISE EXCEPTION 'active edge gateway does not match its consumed enrollment binding';
  201. END IF;
  202. IF EXISTS (
  203. SELECT 1 FROM public.edge_gateway_credentials c
  204. JOIN public.edge_gateway_enrollments e ON e.gateway_id=c.gateway_id
  205. WHERE c.gateway_id=g.uid AND c.generation=1
  206. AND c.certificate_sha256<>e.expected_certificate_sha256
  207. ) THEN
  208. RAISE EXCEPTION 'initial edge certificate does not match enrollment binding';
  209. END IF;
  210. END $$;
  211. CREATE OR REPLACE FUNCTION public.enforce_edge_gateway_binding()
  212. RETURNS trigger LANGUAGE plpgsql AS $$
  213. BEGIN
  214. IF TG_OP='UPDATE' AND OLD.status IN ('active','offline') AND NEW.status='pending' THEN
  215. RAISE EXCEPTION 'registered edge gateway cannot return to pending status';
  216. END IF;
  217. PERFORM public.validate_edge_gateway_binding(COALESCE(NEW.uid,OLD.uid));
  218. RETURN NULL;
  219. END $$;
  220. CREATE CONSTRAINT TRIGGER trg_edge_gateway_binding
  221. AFTER INSERT OR UPDATE ON public.edge_gateways DEFERRABLE INITIALLY DEFERRED
  222. FOR EACH ROW EXECUTE FUNCTION public.enforce_edge_gateway_binding();
  223. CREATE OR REPLACE FUNCTION public.enforce_edge_credential_binding()
  224. RETURNS trigger LANGUAGE plpgsql AS $$
  225. DECLARE target UUID;
  226. BEGIN
  227. target := COALESCE(NEW.gateway_id,OLD.gateway_id);
  228. PERFORM public.validate_edge_gateway_binding(target);
  229. IF TG_OP <> 'DELETE' AND NEW.status='active' AND NOT EXISTS (
  230. SELECT 1 FROM public.edge_gateways g WHERE g.uid=NEW.gateway_id
  231. AND g.status IN ('active','offline') AND g.current_generation=NEW.generation
  232. ) THEN
  233. RAISE EXCEPTION 'active credential must be the exact current gateway binding';
  234. END IF;
  235. RETURN NULL;
  236. END $$;
  237. CREATE CONSTRAINT TRIGGER trg_edge_credential_binding
  238. AFTER INSERT OR UPDATE OR DELETE ON public.edge_gateway_credentials
  239. DEFERRABLE INITIALLY DEFERRED FOR EACH ROW
  240. EXECUTE FUNCTION public.enforce_edge_credential_binding();
  241. CREATE TABLE public.edge_gateway_tasks (
  242. task_id VARCHAR(255) PRIMARY KEY, gateway_id UUID NOT NULL REFERENCES public.edge_gateways(uid),
  243. idempotency_key VARCHAR(255) NOT NULL, contract JSONB NOT NULL, contract_digest CHAR(64) NOT NULL,
  244. status VARCHAR(24) NOT NULL, lease_token_hash CHAR(64), lease_expires_at TIMESTAMPTZ,
  245. cancel_requested_at TIMESTAMPTZ, result_summary JSONB,
  246. created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  247. updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  248. CONSTRAINT uq_edge_task_idempotency UNIQUE(gateway_id,idempotency_key),
  249. CONSTRAINT uq_edge_task_gateway UNIQUE(task_id,gateway_id),
  250. CONSTRAINT ck_edge_task_status CHECK (status IN ('pending','leased','cancel_requested','completed','failed','cancelled')),
  251. CONSTRAINT ck_edge_task_contract CHECK (
  252. jsonb_typeof(contract)='object' AND contract_digest ~ '^[0-9a-f]{64}$' AND
  253. contract->>'task_id'=task_id AND contract->>'gateway_id'=gateway_id::text AND
  254. contract->>'idempotency_key'=idempotency_key),
  255. CONSTRAINT ck_edge_task_lease CHECK (
  256. (status='leased' AND lease_token_hash IS NOT NULL AND lease_expires_at IS NOT NULL) OR
  257. (status='cancel_requested' AND ((lease_token_hash IS NULL AND lease_expires_at IS NULL) OR
  258. (lease_token_hash IS NOT NULL AND lease_expires_at IS NOT NULL))) OR
  259. (status NOT IN ('leased','cancel_requested') AND lease_token_hash IS NULL AND lease_expires_at IS NULL)),
  260. CONSTRAINT ck_edge_task_result CHECK (result_summary IS NULL OR jsonb_typeof(result_summary)='object')
  261. );
  262. CREATE INDEX ix_edge_task_pull ON public.edge_gateway_tasks(gateway_id,status,created_at);
  263. CREATE TABLE public.edge_gateway_events (
  264. event_id VARCHAR(80) PRIMARY KEY, gateway_id UUID NOT NULL REFERENCES public.edge_gateways(uid),
  265. task_id VARCHAR(255) NOT NULL,
  266. classification VARCHAR(40) NOT NULL, contract JSONB NOT NULL, contract_digest CHAR(64) NOT NULL,
  267. ack JSONB NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  268. CONSTRAINT ck_edge_event_classification CHECK (classification IN ('desensitized_metadata','statistics','lineage','evidence','health_summary','diagnostic_summary')),
  269. CONSTRAINT fk_edge_event_task_gateway FOREIGN KEY (task_id,gateway_id)
  270. REFERENCES public.edge_gateway_tasks(task_id,gateway_id),
  271. CONSTRAINT ck_edge_event_contract CHECK (
  272. jsonb_typeof(contract)='object' AND jsonb_typeof(ack)='object' AND
  273. contract_digest ~ '^[0-9a-f]{64}$' AND contract->>'event_id'=event_id AND
  274. contract->>'task_id'=task_id AND contract->>'gateway_id'=gateway_id::text AND
  275. contract->>'classification'=classification)
  276. );
  277. CREATE INDEX ix_edge_event_gateway_received ON public.edge_gateway_events(gateway_id,received_at DESC);
  278. CREATE TABLE public.edge_gateway_releases (
  279. uid UUID PRIMARY KEY, gateway_id UUID NOT NULL REFERENCES public.edge_gateways(uid),
  280. version VARCHAR(80) NOT NULL, artifact_digest CHAR(64) NOT NULL,
  281. rollback_version VARCHAR(80) NOT NULL, status VARCHAR(24) NOT NULL,
  282. request_id VARCHAR(255) NOT NULL, request_digest CHAR(64) NOT NULL,
  283. safe_summary JSONB NOT NULL DEFAULT '{}'::jsonb, created_by UUID NOT NULL,
  284. created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, acknowledged_at TIMESTAMPTZ,
  285. CONSTRAINT uq_edge_release_version UNIQUE(gateway_id,version),
  286. CONSTRAINT uq_edge_release_request UNIQUE(gateway_id,request_id),
  287. CONSTRAINT ck_edge_release_status CHECK (status IN ('offered','accepted','installed','failed','rolled_back')),
  288. CONSTRAINT ck_edge_release_digest CHECK (artifact_digest ~ '^[0-9a-f]{64}$' AND request_digest ~ '^[0-9a-f]{64}$'),
  289. CONSTRAINT ck_edge_release_summary CHECK (jsonb_typeof(safe_summary)='object')
  290. );
  291. CREATE TABLE public.edge_gateway_audits (
  292. uid UUID PRIMARY KEY, gateway_id UUID REFERENCES public.edge_gateways(uid),
  293. event_type VARCHAR(80) NOT NULL, actor_uid UUID, success BOOLEAN NOT NULL,
  294. safe_detail JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  295. CONSTRAINT ck_edge_audit_detail CHECK (jsonb_typeof(safe_detail)='object')
  296. );
  297. CREATE INDEX ix_edge_audit_gateway_created ON public.edge_gateway_audits(gateway_id,created_at DESC);
  298. CREATE TABLE public.edge_gateway_failure_windows (
  299. failure_fingerprint CHAR(64) NOT NULL, window_started_at TIMESTAMPTZ NOT NULL,
  300. gateway_id UUID REFERENCES public.edge_gateways(uid), event_type VARCHAR(80) NOT NULL,
  301. occurrence_count INTEGER NOT NULL, first_seen_at TIMESTAMPTZ NOT NULL,
  302. last_seen_at TIMESTAMPTZ NOT NULL,
  303. PRIMARY KEY(failure_fingerprint,window_started_at),
  304. CONSTRAINT ck_edge_failure_count CHECK (occurrence_count BETWEEN 1 AND 1000000)
  305. );
  306. ALTER TABLE public.edge_gateway_audits ADD COLUMN failure_fingerprint CHAR(64);
  307. ALTER TABLE public.edge_gateway_audits ADD COLUMN window_started_at TIMESTAMPTZ;
  308. CREATE UNIQUE INDEX uq_edge_failure_audit_window
  309. ON public.edge_gateway_audits(failure_fingerprint,window_started_at)
  310. WHERE success=FALSE;
  311. CREATE OR REPLACE FUNCTION public.protect_edge_audit_append_only()
  312. RETURNS trigger LANGUAGE plpgsql AS $$
  313. BEGIN
  314. IF TG_OP='INSERT' AND current_user='dataops_edge_evidence_owner' THEN
  315. RETURN NEW;
  316. END IF;
  317. RAISE EXCEPTION 'edge gateway audit ledger is owner-written and append-only';
  318. END $$;
  319. CREATE TRIGGER trg_edge_audit_append_only
  320. BEFORE INSERT OR UPDATE OR DELETE ON public.edge_gateway_audits
  321. FOR EACH ROW EXECUTE FUNCTION public.protect_edge_audit_append_only();
  322. CREATE TRIGGER trg_edge_audit_no_truncate
  323. BEFORE TRUNCATE ON public.edge_gateway_audits
  324. FOR EACH STATEMENT EXECUTE FUNCTION public.protect_edge_audit_append_only();
  325. CREATE OR REPLACE FUNCTION public.protect_edge_failure_window()
  326. RETURNS trigger LANGUAGE plpgsql AS $$
  327. BEGIN
  328. IF TG_OP IN ('DELETE','TRUNCATE') THEN
  329. RAISE EXCEPTION 'edge gateway failure evidence cannot be removed';
  330. END IF;
  331. IF current_user<>'dataops_edge_evidence_owner' THEN
  332. RAISE EXCEPTION 'edge gateway failure evidence requires the controlled writer';
  333. END IF;
  334. IF TG_OP='INSERT' THEN RETURN NEW; END IF;
  335. IF NEW.failure_fingerprint<>OLD.failure_fingerprint OR
  336. NEW.window_started_at<>OLD.window_started_at OR
  337. NEW.gateway_id IS DISTINCT FROM OLD.gateway_id OR
  338. NEW.event_type<>OLD.event_type OR NEW.first_seen_at<>OLD.first_seen_at THEN
  339. RAISE EXCEPTION 'edge gateway failure window identity is immutable';
  340. END IF;
  341. IF NEW.occurrence_count<>OLD.occurrence_count+1 OR
  342. NEW.last_seen_at<OLD.last_seen_at OR NEW.last_seen_at<NEW.first_seen_at THEN
  343. RAISE EXCEPTION 'edge gateway failure window update is invalid';
  344. END IF;
  345. RETURN NEW;
  346. END $$;
  347. CREATE TRIGGER trg_edge_failure_window_protected
  348. BEFORE INSERT OR UPDATE OR DELETE ON public.edge_gateway_failure_windows
  349. FOR EACH ROW EXECUTE FUNCTION public.protect_edge_failure_window();
  350. CREATE TRIGGER trg_edge_failure_window_no_truncate
  351. BEFORE TRUNCATE ON public.edge_gateway_failure_windows
  352. FOR EACH STATEMENT EXECUTE FUNCTION public.protect_edge_failure_window();
  353. CREATE OR REPLACE FUNCTION public.record_edge_gateway_audit(
  354. p_uid UUID,p_gateway UUID,p_event_type VARCHAR(80),p_actor UUID,
  355. p_success BOOLEAN,p_detail JSONB
  356. ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER
  357. SET search_path=pg_catalog AS $$
  358. BEGIN
  359. IF p_uid IS NULL OR p_event_type NOT IN (
  360. 'enrollment_created','gateway_registered','credential_rotated','gateway_revoked',
  361. 'heartbeat_accepted','task_issued','task_cancel_requested','task_outcome_recorded',
  362. 'event_accepted','release_offered','release_acknowledged'
  363. ) OR jsonb_typeof(p_detail)<>'object' OR octet_length(p_detail::text)>8192 OR
  364. (p_gateway IS NOT NULL AND NOT EXISTS (
  365. SELECT 1 FROM public.edge_gateways WHERE uid=p_gateway
  366. )) THEN
  367. RAISE EXCEPTION 'edge gateway audit evidence is invalid';
  368. END IF;
  369. INSERT INTO public.edge_gateway_audits
  370. (uid,gateway_id,event_type,actor_uid,success,safe_detail)
  371. VALUES (p_uid,p_gateway,p_event_type,p_actor,p_success,p_detail);
  372. END $$;
  373. CREATE OR REPLACE FUNCTION public.record_edge_gateway_failure(
  374. p_audit_uid UUID,p_fingerprint CHAR(64),p_gateway UUID,
  375. p_event_type VARCHAR(80),p_reason_code VARCHAR(64)
  376. ) RETURNS TABLE(
  377. failure_fingerprint CHAR(64), window_started_at TIMESTAMPTZ,
  378. occurrence_count INTEGER, gateway_id UUID, event_type VARCHAR(80)
  379. ) LANGUAGE plpgsql SECURITY DEFINER
  380. SET search_path=pg_catalog AS $$
  381. DECLARE
  382. target_fingerprint CHAR(64) := p_fingerprint;
  383. target_gateway UUID := p_gateway;
  384. target_event_type VARCHAR(80) := p_event_type;
  385. target_reason_code VARCHAR(64) := p_reason_code;
  386. target_window TIMESTAMPTZ := date_trunc('minute',CURRENT_TIMESTAMP);
  387. total_windows INTEGER;
  388. BEGIN
  389. IF p_audit_uid IS NULL OR p_fingerprint !~ '^[0-9a-f]{64}$' OR
  390. p_event_type NOT IN (
  391. 'untrusted_request_rejected','policy_rejected','binding_rejected',
  392. 'idempotency_conflict','release_rejected'
  393. ) OR p_reason_code !~ '^[a-z][a-z0-9_]{0,63}$' OR
  394. (p_gateway IS NOT NULL AND NOT EXISTS (
  395. SELECT 1 FROM public.edge_gateways WHERE uid=p_gateway
  396. )) THEN
  397. RAISE EXCEPTION 'edge gateway failure evidence is invalid';
  398. END IF;
  399. PERFORM pg_advisory_xact_lock(4774096);
  400. SELECT count(*) INTO total_windows FROM public.edge_gateway_failure_windows;
  401. IF NOT EXISTS (
  402. SELECT 1 FROM public.edge_gateway_failure_windows AS existing
  403. WHERE existing.failure_fingerprint=target_fingerprint
  404. AND existing.window_started_at=target_window
  405. ) AND total_windows>=4095 THEN
  406. target_fingerprint := '16b5bae3613ee26975528e149d401a46f1d659405cfe0148262caec288dfb365';
  407. target_gateway := NULL;
  408. target_event_type := 'untrusted_request_rejected';
  409. target_reason_code := 'capacity_overflow';
  410. target_window := '1970-01-01 00:00:00+00'::timestamptz;
  411. END IF;
  412. RETURN QUERY
  413. INSERT INTO public.edge_gateway_failure_windows AS windows
  414. (failure_fingerprint,window_started_at,gateway_id,event_type,
  415. occurrence_count,first_seen_at,last_seen_at)
  416. VALUES (target_fingerprint,target_window,target_gateway,target_event_type,
  417. 1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
  418. ON CONFLICT ON CONSTRAINT edge_gateway_failure_windows_pkey DO UPDATE
  419. SET occurrence_count=windows.occurrence_count+1,
  420. last_seen_at=CURRENT_TIMESTAMP
  421. RETURNING windows.failure_fingerprint,windows.window_started_at,
  422. windows.occurrence_count,windows.gateway_id,windows.event_type;
  423. IF NOT EXISTS (
  424. SELECT 1 FROM public.edge_gateway_audits AS existing_audit
  425. WHERE existing_audit.failure_fingerprint=target_fingerprint
  426. AND existing_audit.window_started_at=target_window
  427. AND existing_audit.success=FALSE
  428. ) THEN
  429. INSERT INTO public.edge_gateway_audits
  430. (uid,gateway_id,event_type,actor_uid,success,safe_detail,
  431. failure_fingerprint,window_started_at)
  432. VALUES (p_audit_uid,target_gateway,target_event_type,NULL,FALSE,
  433. jsonb_build_object('reason_code',target_reason_code),
  434. target_fingerprint,target_window);
  435. END IF;
  436. END $$;
  437. ALTER TABLE public.edge_gateway_audits OWNER TO dataops_edge_evidence_owner;
  438. ALTER TABLE public.edge_gateway_failure_windows OWNER TO dataops_edge_evidence_owner;
  439. ALTER FUNCTION public.protect_edge_audit_append_only() OWNER TO dataops_edge_evidence_owner;
  440. ALTER FUNCTION public.protect_edge_failure_window() OWNER TO dataops_edge_evidence_owner;
  441. ALTER FUNCTION public.record_edge_gateway_audit(UUID,UUID,VARCHAR(80),UUID,BOOLEAN,JSONB)
  442. OWNER TO dataops_edge_evidence_owner;
  443. ALTER FUNCTION public.record_edge_gateway_failure(UUID,CHAR(64),UUID,VARCHAR(80),VARCHAR(64))
  444. OWNER TO dataops_edge_evidence_owner;
  445. GRANT SELECT ON public.edge_gateways TO dataops_edge_evidence_owner;
  446. REVOKE ALL ON public.edge_gateway_audits,public.edge_gateway_failure_windows FROM PUBLIC;
  447. REVOKE ALL ON FUNCTION public.record_edge_gateway_audit(UUID,UUID,VARCHAR(80),UUID,BOOLEAN,JSONB) FROM PUBLIC;
  448. REVOKE ALL ON FUNCTION public.record_edge_gateway_failure(UUID,CHAR(64),UUID,VARCHAR(80),VARCHAR(64)) FROM PUBLIC;
  449. GRANT USAGE ON SCHEMA public TO dataops_app_runtime;
  450. GRANT SELECT,INSERT,UPDATE,DELETE ON
  451. public.edge_gateway_enrollments,public.edge_gateways,
  452. public.edge_gateway_credentials,public.edge_gateway_rotation_requests,
  453. public.edge_gateway_tasks,public.edge_gateway_events,public.edge_gateway_releases
  454. TO dataops_app_runtime;
  455. ALTER DEFAULT PRIVILEGES IN SCHEMA public
  456. GRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO dataops_app_runtime;
  457. ALTER DEFAULT PRIVILEGES IN SCHEMA public
  458. GRANT USAGE,SELECT,UPDATE ON SEQUENCES TO dataops_app_runtime;
  459. REVOKE INSERT,UPDATE,DELETE,TRUNCATE ON
  460. public.edge_gateway_audits,public.edge_gateway_failure_windows
  461. FROM dataops_app_runtime;
  462. GRANT SELECT ON public.edge_gateway_audits,public.edge_gateway_failure_windows
  463. TO dataops_app_runtime;
  464. GRANT EXECUTE ON FUNCTION public.record_edge_gateway_audit(UUID,UUID,VARCHAR(80),UUID,BOOLEAN,JSONB)
  465. TO dataops_app_runtime;
  466. GRANT EXECUTE ON FUNCTION public.record_edge_gateway_failure(UUID,CHAR(64),UUID,VARCHAR(80),VARCHAR(64))
  467. TO dataops_app_runtime;
  468. """)
  469. def downgrade() -> None:
  470. op.execute("""
  471. DO $$
  472. DECLARE table_name TEXT; has_data BOOLEAN;
  473. BEGIN
  474. FOREACH table_name IN ARRAY ARRAY[
  475. 'edge_gateway_enrollments','edge_gateways','edge_gateway_credentials',
  476. 'edge_gateway_tasks','edge_gateway_events','edge_gateway_releases',
  477. 'edge_gateway_audits','edge_gateway_failure_windows','edge_gateway_rotation_requests'
  478. ] LOOP
  479. IF to_regclass('public.' || table_name) IS NOT NULL THEN
  480. EXECUTE format('SELECT EXISTS (SELECT 1 FROM public.%I)',table_name) INTO has_data;
  481. IF has_data THEN
  482. RAISE EXCEPTION 'edge gateway downgrade rejected: clear Task2 business and audit data first';
  483. END IF;
  484. END IF;
  485. END LOOP;
  486. END $$;
  487. DROP TRIGGER IF EXISTS trg_edge_audit_append_only ON public.edge_gateway_audits;
  488. DROP TRIGGER IF EXISTS trg_edge_audit_no_truncate ON public.edge_gateway_audits;
  489. DROP TRIGGER IF EXISTS trg_edge_failure_window_protected ON public.edge_gateway_failure_windows;
  490. DROP TRIGGER IF EXISTS trg_edge_failure_window_no_truncate ON public.edge_gateway_failure_windows;
  491. DROP FUNCTION IF EXISTS public.record_edge_gateway_failure(UUID,CHAR(64),UUID,VARCHAR(80),VARCHAR(64));
  492. DROP FUNCTION IF EXISTS public.record_edge_gateway_audit(UUID,UUID,VARCHAR(80),UUID,BOOLEAN,JSONB);
  493. DROP FUNCTION IF EXISTS public.protect_edge_failure_window();
  494. DROP FUNCTION IF EXISTS public.protect_edge_audit_append_only();
  495. DROP TABLE IF EXISTS public.edge_gateway_failure_windows;
  496. DROP TABLE IF EXISTS public.edge_gateway_audits;
  497. DROP TABLE IF EXISTS public.edge_gateway_events;
  498. DROP TABLE IF EXISTS public.edge_gateway_releases;
  499. DROP TABLE IF EXISTS public.edge_gateway_tasks;
  500. DROP TABLE IF EXISTS public.edge_gateway_rotation_requests;
  501. DROP TRIGGER IF EXISTS trg_edge_credential_identity_immutable ON public.edge_gateway_credentials;
  502. DROP TRIGGER IF EXISTS trg_edge_gateway_identity_immutable ON public.edge_gateways;
  503. DROP TRIGGER IF EXISTS trg_edge_credential_binding ON public.edge_gateway_credentials;
  504. DROP TRIGGER IF EXISTS trg_edge_gateway_binding ON public.edge_gateways;
  505. DROP FUNCTION IF EXISTS public.enforce_edge_credential_binding();
  506. DROP FUNCTION IF EXISTS public.enforce_edge_gateway_binding();
  507. DROP FUNCTION IF EXISTS public.validate_edge_gateway_binding(UUID);
  508. DROP FUNCTION IF EXISTS public.protect_edge_credential_identity();
  509. DROP FUNCTION IF EXISTS public.protect_edge_gateway_identity();
  510. DROP TABLE IF EXISTS public.edge_gateway_credentials;
  511. DROP TABLE IF EXISTS public.edge_gateways;
  512. DROP TRIGGER IF EXISTS trg_edge_enrollment_identity_immutable ON public.edge_gateway_enrollments;
  513. DROP TABLE IF EXISTS public.edge_gateway_enrollments;
  514. DROP FUNCTION IF EXISTS public.protect_edge_enrollment_identity();
  515. DROP FUNCTION IF EXISTS public.edge_hosts_valid(TEXT[],BOOLEAN);
  516. """)