test_phase3_wp04_edge_gateway_postgres.py 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945
  1. from __future__ import annotations
  2. import hashlib
  3. import os
  4. import subprocess
  5. import uuid
  6. from concurrent.futures import ThreadPoolExecutor
  7. from datetime import UTC, datetime, timedelta
  8. from pathlib import Path
  9. from threading import Barrier, Event
  10. from types import SimpleNamespace
  11. from urllib.parse import quote
  12. import pytest
  13. from cryptography import x509
  14. from cryptography.hazmat.primitives import hashes, serialization
  15. from cryptography.hazmat.primitives.asymmetric import rsa
  16. from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
  17. from cryptography.x509.oid import NameOID
  18. from sqlalchemy import create_engine, inspect, text
  19. from sqlalchemy.engine import make_url
  20. from sqlalchemy.exc import DBAPIError
  21. from sqlalchemy.orm import Session
  22. from app.config.config import validate_production_database_identity
  23. from app.core.edge_gateway.contracts import (
  24. EdgeEventContract,
  25. SignedTaskEnvelope,
  26. canonical_json_bytes,
  27. canonical_sha256,
  28. stable_event_id,
  29. )
  30. from app.core.edge_gateway.repository import EdgeGatewayRepository
  31. from app.core.edge_gateway.service import (
  32. EdgeGatewayAuthenticationError,
  33. EdgeGatewayConfigurationError,
  34. EdgeGatewayConflictError,
  35. EdgeGatewayError,
  36. EdgeGatewayService,
  37. EdgeGatewayValidationError,
  38. )
  39. from app.edge_gateway.agent import EdgeAgent, EdgeRunnerAdapter, SignedReleaseManager
  40. from app.edge_gateway.bootstrap import EdgeBootstrapConfig
  41. ROOT = Path(__file__).resolve().parents[2]
  42. DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
  43. pytestmark = pytest.mark.integration
  44. _EVIDENCE_TRIGGERS = {
  45. "edge_gateway_audits": (
  46. "trg_edge_audit_append_only",
  47. "trg_edge_audit_no_truncate",
  48. ),
  49. "edge_gateway_failure_windows": (
  50. "trg_edge_failure_window_protected",
  51. "trg_edge_failure_window_no_truncate",
  52. ),
  53. }
  54. _SIGNING_PRIVATE_KEY = Ed25519PrivateKey.generate()
  55. _SIGNING_KEY_ID = "edge-control-test-key-1"
  56. def _service(repository):
  57. return EdgeGatewayService(
  58. repository,
  59. signing_private_key=_SIGNING_PRIVATE_KEY,
  60. signing_key_id=_SIGNING_KEY_ID,
  61. )
  62. def _uid() -> str:
  63. return str(uuid.uuid4())
  64. def _alembic(command: str, target: str, database_url: str = None) -> None:
  65. assert DATABASE_URL
  66. subprocess.run(
  67. [str(ROOT / ".venv/bin/alembic"), "-c", str(ROOT / "alembic.ini"), command, target],
  68. cwd=ROOT,
  69. env={
  70. **os.environ,
  71. "MIGRATION_DATABASE_URL": database_url or DATABASE_URL,
  72. "SQLALCHEMY_DATABASE_URI": "",
  73. "DATABASE_URL": "",
  74. },
  75. check=True,
  76. capture_output=True,
  77. text=True,
  78. )
  79. def _clear_edge_rows_for_test(engine) -> None:
  80. tables = set(inspect(engine).get_table_names(schema="public"))
  81. targets = []
  82. for table in (
  83. "edge_gateway_failure_windows", "edge_gateway_audits", "edge_gateway_events",
  84. "edge_gateway_releases", "edge_gateway_tasks", "edge_gateway_rotation_requests",
  85. "edge_gateway_credentials", "edge_gateways", "edge_gateway_enrollments",
  86. ):
  87. if table in tables:
  88. targets.append(f"public.{table}")
  89. if not targets:
  90. return
  91. with engine.begin() as connection:
  92. existing_triggers = set(connection.execute(text("""
  93. SELECT tgname FROM pg_trigger
  94. WHERE tgrelid IN (
  95. 'public.edge_gateway_audits'::regclass,
  96. to_regclass('public.edge_gateway_failure_windows')
  97. ) AND NOT tgisinternal
  98. """)).scalars()) if "edge_gateway_audits" in tables else set()
  99. disabled = []
  100. for table, triggers in _EVIDENCE_TRIGGERS.items():
  101. if table not in tables:
  102. continue
  103. for trigger in triggers:
  104. if trigger in existing_triggers:
  105. connection.execute(text(f"ALTER TABLE public.{table} DISABLE TRIGGER {trigger}"))
  106. disabled.append((table, trigger))
  107. connection.execute(text(f"TRUNCATE TABLE {','.join(targets)} CASCADE"))
  108. for table, trigger in disabled:
  109. connection.execute(text(f"ALTER TABLE public.{table} ENABLE TRIGGER {trigger}"))
  110. @pytest.fixture(scope="module")
  111. def engine():
  112. if not DATABASE_URL:
  113. pytest.skip("TEST_DATABASE_URL is required")
  114. bootstrap = create_engine(DATABASE_URL, pool_pre_ping=True)
  115. _clear_edge_rows_for_test(bootstrap)
  116. bootstrap.dispose()
  117. _alembic("downgrade", "20260802_476")
  118. _alembic("upgrade", "head")
  119. value = create_engine(DATABASE_URL, pool_pre_ping=True)
  120. yield value
  121. value.dispose()
  122. @pytest.fixture(autouse=True)
  123. def clear_edge_rows(engine):
  124. _clear_edge_rows_for_test(engine)
  125. yield
  126. _clear_edge_rows_for_test(engine)
  127. @pytest.fixture
  128. def service(engine):
  129. session = Session(engine)
  130. yield _service(EdgeGatewayRepository(session))
  131. session.close()
  132. def _enrollment(service, **overrides):
  133. values = {
  134. "gateway_name": "plant-a-edge",
  135. "environment": "staging",
  136. "network_zone": "manufacturing-zone-a",
  137. "policy_digest": "a" * 64,
  138. "allowed_control_hosts": ["control.example.test"],
  139. "allowed_proxy_hosts": ["proxy.example.test"],
  140. "expected_certificate_sha256": "b" * 64,
  141. "ttl_seconds": 600,
  142. "actor_uid": _uid(),
  143. }
  144. values.update(overrides)
  145. return service.create_enrollment(**values)
  146. def _register(service, enrollment, certificate_sha256="b" * 64):
  147. return service.register(
  148. enrollment_token=enrollment["enrollment_token"],
  149. certificate_sha256=certificate_sha256,
  150. gateway_id=enrollment["gateway_id"],
  151. environment="staging",
  152. network_zone="manufacturing-zone-a",
  153. policy_digest="a" * 64,
  154. allowed_control_hosts=["control.example.test"],
  155. allowed_proxy_hosts=["proxy.example.test"],
  156. version="3.0.0",
  157. )
  158. def _event(task, gateway, classification="statistics", payload=None):
  159. identity = {
  160. "task_id": task["task_id"], "gateway_id": gateway["gateway_id"],
  161. "environment": "staging", "network_zone": "manufacturing-zone-a",
  162. "purpose": "inventory", "classification": classification,
  163. "contract_version": 1,
  164. "occurred_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
  165. "attempt": 1, "idempotency_key": "evt-key-1", "policy_digest": "a" * 64,
  166. "payload": payload or {"asset_count": 12},
  167. }
  168. return {"event_id": stable_event_id(identity), **identity}
  169. def test_migration_is_479_head_and_schema_is_constrained(engine):
  170. with engine.connect() as connection:
  171. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_479"
  172. functions = connection.execute(text("""
  173. SELECT pg_get_function_identity_arguments(p.oid),r.rolname,p.prosecdef,p.proconfig
  174. FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
  175. JOIN pg_roles r ON r.oid=p.proowner
  176. WHERE n.nspname='public' AND p.proname='record_edge_gateway_failure'
  177. """)).all()
  178. constraints = connection.execute(text("""
  179. SELECT conname FROM pg_constraint
  180. WHERE conrelid IN ('public.edge_gateways'::regclass,
  181. 'public.edge_gateway_credentials'::regclass)
  182. """)).scalars().all()
  183. assert "ck_edge_gateway_status" in constraints
  184. assert "ck_edge_credential_status" in constraints
  185. assert "trg_edge_gateway_binding" in constraints
  186. assert "trg_edge_credential_binding" in constraints
  187. assert functions == [(
  188. "p_audit_uid uuid, p_fingerprint character, p_gateway uuid, p_event_type character varying, p_reason_code character varying",
  189. "dataops_edge_evidence_owner", True, ["search_path=pg_catalog"],
  190. )]
  191. enrollment_columns = {column["name"] for column in inspect(engine).get_columns("edge_gateway_enrollments", schema="public")}
  192. assert "expected_certificate_sha256" in enrollment_columns
  193. task_columns = {
  194. column["name"]
  195. for column in inspect(engine).get_columns("edge_gateway_tasks", schema="public")
  196. }
  197. release_columns = {
  198. column["name"]
  199. for column in inspect(engine).get_columns("edge_gateway_releases", schema="public")
  200. }
  201. assert "authority" in task_columns
  202. assert {
  203. "artifact_name", "deadline_at", "signature_algorithm", "key_id",
  204. "manifest_digest", "signature", "signed_manifest",
  205. } <= release_columns
  206. with pytest.raises(DBAPIError), engine.begin() as connection:
  207. connection.execute(text("""
  208. INSERT INTO edge_gateway_enrollments
  209. (uid,gateway_id,gateway_name,environment,network_zone,policy_digest,
  210. allowed_control_hosts,allowed_proxy_hosts,token_hash,status,expires_at,created_by)
  211. VALUES (CAST(:uid AS uuid),CAST(:gateway AS uuid),'invalid-host','staging','zone-a',
  212. :policy,ARRAY['control.example.test','CONTROL.example.test'],ARRAY[]::text[],
  213. :token,'pending',CURRENT_TIMESTAMP+INTERVAL '10 minutes',CAST(:actor AS uuid))
  214. """), {"uid": _uid(), "gateway": _uid(), "actor": _uid(), "policy": "1" * 64, "token": "2" * 64})
  215. def test_migration_controlled_477_downgrade_and_476_upgrade(engine):
  216. try:
  217. _alembic("downgrade", "20260802_476")
  218. with engine.connect() as connection:
  219. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260802_476"
  220. assert not inspect(connection).has_table("edge_gateways", schema="public")
  221. finally:
  222. _alembic("upgrade", "head")
  223. with engine.connect() as connection:
  224. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_479"
  225. def test_479_downgrade_and_upgrade_are_reversible_without_signed_rows(engine):
  226. try:
  227. _alembic("downgrade", "20260809_478")
  228. with engine.connect() as connection:
  229. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_478"
  230. assert "authority" not in {
  231. column["name"]
  232. for column in inspect(connection).get_columns("edge_gateway_tasks", schema="public")
  233. }
  234. finally:
  235. _alembic("upgrade", "head")
  236. with engine.connect() as connection:
  237. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_479"
  238. def test_478_downgrade_never_restores_unsafe_function(engine):
  239. try:
  240. _alembic("downgrade", "20260809_477")
  241. with engine.connect() as connection:
  242. signatures = connection.execute(text("""
  243. SELECT pg_get_function_identity_arguments(p.oid)
  244. FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
  245. WHERE n.nspname='public' AND p.proname='record_edge_gateway_failure'
  246. ORDER BY 1
  247. """)).scalars().all()
  248. assert signatures == [
  249. "p_audit_uid uuid, p_fingerprint character, p_gateway uuid, p_event_type character varying, p_reason_code character varying"
  250. ]
  251. finally:
  252. _alembic("upgrade", "head")
  253. def test_migration_rejects_downgrade_with_task2_data(service, engine):
  254. _enrollment(service)
  255. with pytest.raises(subprocess.CalledProcessError):
  256. _alembic("downgrade", "20260802_476")
  257. with engine.connect() as connection:
  258. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_479"
  259. _clear_edge_rows_for_test(engine)
  260. with engine.begin() as connection:
  261. connection.execute(text("""
  262. SELECT public.record_edge_gateway_audit(
  263. CAST(:uid AS uuid),NULL,'enrollment_created',NULL,FALSE,
  264. '{"reason_code":"controlled_downgrade_probe"}'::jsonb
  265. )
  266. """), {"uid": _uid()})
  267. with pytest.raises(subprocess.CalledProcessError):
  268. _alembic("downgrade", "20260802_476")
  269. with engine.connect() as connection:
  270. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_479"
  271. def test_enrollment_registration_hash_only_replay_and_failed_binding(service, engine):
  272. enrollment = _enrollment(service)
  273. assert enrollment["returned_once"] is True
  274. token = enrollment["enrollment_token"]
  275. with engine.connect() as connection:
  276. row = connection.execute(text("SELECT token_hash, consumed_at FROM edge_gateway_enrollments")).mappings().one()
  277. assert row["token_hash"] == hashlib.sha256(token.encode()).hexdigest()
  278. assert token not in str(row)
  279. with pytest.raises(DBAPIError), engine.begin() as connection:
  280. connection.execute(text("""
  281. UPDATE edge_gateway_enrollments SET expected_certificate_sha256=:certificate
  282. WHERE gateway_id=CAST(:gateway AS uuid)
  283. """), {"certificate": "c" * 64, "gateway": enrollment["gateway_id"]})
  284. with pytest.raises(EdgeGatewayAuthenticationError):
  285. _register(service, enrollment, certificate_sha256="c" * 64)
  286. with engine.connect() as connection:
  287. assert connection.execute(text("SELECT consumed_at FROM edge_gateway_enrollments")).scalar_one() is None
  288. failures = connection.execute(text("SELECT safe_detail FROM edge_gateway_audits WHERE success=false")).scalars().all()
  289. assert failures and all(token not in str(detail) for detail in failures)
  290. with pytest.raises(EdgeGatewayAuthenticationError):
  291. service.register(
  292. enrollment_token=token, certificate_sha256="b" * 64,
  293. gateway_id=enrollment["gateway_id"], environment="production",
  294. network_zone="manufacturing-zone-a", policy_digest="a" * 64,
  295. allowed_control_hosts=["control.example.test"],
  296. allowed_proxy_hosts=["proxy.example.test"], version="3.0.0",
  297. )
  298. with engine.connect() as connection:
  299. assert connection.execute(text("SELECT consumed_at FROM edge_gateway_enrollments")).scalar_one() is None
  300. gateway = _register(service, enrollment)
  301. assert gateway["credential_returned_once"] is True
  302. with engine.connect() as connection:
  303. credential = connection.execute(text("SELECT credential_hash, certificate_sha256, generation FROM edge_gateway_credentials")).mappings().one()
  304. assert gateway["credential"] not in str(credential)
  305. assert credential["certificate_sha256"] == "b" * 64
  306. assert credential["generation"] == 1
  307. with pytest.raises(EdgeGatewayAuthenticationError):
  308. _register(service, enrollment)
  309. def test_registration_failures_are_windowed_rate_limited_and_do_not_consume(service, engine):
  310. enrollment = _enrollment(service)
  311. for _ in range(9):
  312. with pytest.raises(EdgeGatewayAuthenticationError):
  313. _register(service, enrollment, certificate_sha256="c" * 64)
  314. with pytest.raises(EdgeGatewayError, match="rate limit") as limited:
  315. _register(service, enrollment, certificate_sha256="c" * 64)
  316. assert limited.value.status_code == 429
  317. with engine.connect() as connection:
  318. failure = connection.execute(text("""
  319. SELECT occurrence_count FROM edge_gateway_failure_windows
  320. WHERE event_type='untrusted_request_rejected'
  321. """)).scalar_one()
  322. audit = connection.execute(text("""
  323. SELECT count(*),string_agg(safe_detail::text,'')
  324. FROM edge_gateway_audits WHERE event_type='untrusted_request_rejected'
  325. """)).one()
  326. consumed_at = connection.execute(text("""
  327. SELECT consumed_at FROM edge_gateway_enrollments
  328. WHERE gateway_id=CAST(:gateway_id AS uuid)
  329. """), {"gateway_id": enrollment["gateway_id"]}).scalar_one()
  330. assert failure == 10
  331. assert audit[0] == 1
  332. assert enrollment["enrollment_token"] not in (audit[1] or "")
  333. assert consumed_at is None
  334. assert _register(service, enrollment)["generation"] == 1
  335. def test_mixed_unauthenticated_failures_share_one_bounded_unknown_bucket(service, engine):
  336. limited = None
  337. for attempt in range(10):
  338. gateway_id = _uid()
  339. try:
  340. if attempt % 2:
  341. service.register(
  342. enrollment_token=f"dope_invalid_{attempt}",
  343. certificate_sha256="b" * 64, gateway_id=gateway_id,
  344. environment="staging", network_zone=f"random-zone-{attempt}",
  345. policy_digest="a" * 64,
  346. allowed_control_hosts=["control.example.test"],
  347. allowed_proxy_hosts=[], version="3.0.0",
  348. )
  349. else:
  350. service.authenticate(
  351. credential=f"dopg_invalid_{attempt}", certificate_sha256="b" * 64,
  352. gateway_id=gateway_id, environment="staging",
  353. network_zone=f"random-zone-{attempt}", generation=1,
  354. )
  355. except EdgeGatewayError as exc:
  356. limited = exc
  357. assert limited is not None and limited.status_code == 429
  358. with engine.connect() as connection:
  359. windows = connection.execute(text("""
  360. SELECT failure_fingerprint,gateway_id,event_type,occurrence_count
  361. FROM edge_gateway_failure_windows
  362. """)).mappings().all()
  363. audits = connection.execute(text("""
  364. SELECT gateway_id,event_type,safe_detail FROM edge_gateway_audits WHERE success=false
  365. """)).mappings().all()
  366. assert len(windows) == 1 and windows[0]["occurrence_count"] == 10
  367. assert windows[0]["gateway_id"] is None
  368. assert windows[0]["event_type"] == "untrusted_request_rejected"
  369. assert len(audits) == 1 and audits[0]["gateway_id"] is None
  370. assert audits[0]["safe_detail"] == {"reason_code": "untrusted_failure"}
  371. def test_database_rejects_pending_enrollment_and_status_only_gateway_activation(service, engine):
  372. enrollment = _enrollment(service)
  373. values = {
  374. "gateway_id": enrollment["gateway_id"], "policy": "a" * 64,
  375. }
  376. with pytest.raises(DBAPIError), engine.begin() as connection:
  377. connection.execute(text("""
  378. INSERT INTO edge_gateways
  379. (uid,name,environment,network_zone,status,current_generation,policy_digest,
  380. allowed_control_hosts,allowed_proxy_hosts,runtime_version)
  381. VALUES (CAST(:gateway_id AS uuid),'plant-a-edge','staging','manufacturing-zone-a',
  382. 'active',1,:policy,ARRAY['control.example.test'],
  383. ARRAY['proxy.example.test'],'3.0.0')
  384. """), values)
  385. with engine.begin() as connection:
  386. connection.execute(text("""
  387. INSERT INTO edge_gateways
  388. (uid,name,environment,network_zone,status,current_generation,policy_digest,
  389. allowed_control_hosts,allowed_proxy_hosts,runtime_version)
  390. VALUES (CAST(:gateway_id AS uuid),'plant-a-edge','staging','manufacturing-zone-a',
  391. 'pending',1,:policy,ARRAY['control.example.test'],
  392. ARRAY['proxy.example.test'],'3.0.0')
  393. """), values)
  394. with pytest.raises(DBAPIError), engine.begin() as connection:
  395. connection.execute(text("""
  396. UPDATE edge_gateways SET status='active'
  397. WHERE uid=CAST(:gateway_id AS uuid)
  398. """), values)
  399. def test_authentication_rotation_revocation_heartbeat_and_active_binding(service, engine):
  400. gateway = _register(service, _enrollment(service))
  401. identity = service.authenticate(
  402. credential=gateway["credential"], certificate_sha256="b" * 64,
  403. gateway_id=gateway["gateway_id"], environment="staging",
  404. network_zone="manufacturing-zone-a", generation=1,
  405. )
  406. assert identity["gateway_id"] == gateway["gateway_id"]
  407. for statement, parameters in (
  408. ("UPDATE edge_gateway_credentials SET certificate_sha256=:value WHERE gateway_id=CAST(:gateway AS uuid)", {"value": "c" * 64}),
  409. ("UPDATE edge_gateway_credentials SET credential_hash=:value WHERE gateway_id=CAST(:gateway AS uuid)", {"value": "d" * 64}),
  410. ("UPDATE edge_gateways SET name='attacker' WHERE uid=CAST(:gateway AS uuid)", {}),
  411. ("UPDATE edge_gateways SET environment='production' WHERE uid=CAST(:gateway AS uuid)", {}),
  412. ("UPDATE edge_gateways SET network_zone='attacker-zone' WHERE uid=CAST(:gateway AS uuid)", {}),
  413. ("UPDATE edge_gateways SET policy_digest=:value WHERE uid=CAST(:gateway AS uuid)", {"value": "c" * 64}),
  414. ("UPDATE edge_gateways SET allowed_control_hosts=ARRAY['attacker.test'] WHERE uid=CAST(:gateway AS uuid)", {}),
  415. ("UPDATE edge_gateways SET allowed_proxy_hosts=ARRAY['attacker.test'] WHERE uid=CAST(:gateway AS uuid)", {}),
  416. ("""UPDATE edge_gateway_credentials SET generation=2,certificate_sha256=:certificate,
  417. credential_hash=:credential WHERE gateway_id=CAST(:gateway AS uuid);
  418. UPDATE edge_gateways SET current_generation=2 WHERE uid=CAST(:gateway AS uuid)""",
  419. {"certificate": "c" * 64, "credential": "d" * 64}),
  420. ):
  421. with pytest.raises(DBAPIError), engine.begin() as connection:
  422. connection.execute(text(statement), {"gateway": gateway["gateway_id"], **parameters})
  423. with pytest.raises(DBAPIError), engine.begin() as connection:
  424. connection.execute(text("""
  425. UPDATE edge_gateway_credentials SET status='rotated',revoked_at=CURRENT_TIMESTAMP
  426. WHERE gateway_id=CAST(:gateway_id AS uuid) AND generation=1
  427. """), {"gateway_id": gateway["gateway_id"]})
  428. with pytest.raises(EdgeGatewayAuthenticationError):
  429. service.authenticate(
  430. credential=gateway["credential"], certificate_sha256="c" * 64,
  431. gateway_id=gateway["gateway_id"], environment="staging",
  432. network_zone="manufacturing-zone-a", generation=1,
  433. )
  434. with pytest.raises(EdgeGatewayValidationError):
  435. service.heartbeat(
  436. credential=gateway["credential"], certificate_sha256="b" * 64,
  437. gateway_id=gateway["gateway_id"], environment="staging",
  438. network_zone="manufacturing-zone-a", generation=1, version="3.0.0",
  439. safe_summary={"password": "must-not-persist"},
  440. )
  441. with engine.connect() as connection:
  442. assert connection.execute(text("SELECT last_heartbeat_at FROM edge_gateways")).scalar_one() is None
  443. rotation_actor = _uid()
  444. rotated = service.rotate(
  445. gateway["gateway_id"], certificate_sha256="c" * 64,
  446. request_id="rotation-request-1", actor_uid=rotation_actor,
  447. )
  448. rotation_replay = service.rotate(
  449. gateway["gateway_id"], certificate_sha256="c" * 64,
  450. request_id="rotation-request-1", actor_uid=rotation_actor,
  451. )
  452. assert rotation_replay == {
  453. "gateway_id": gateway["gateway_id"], "generation": 2,
  454. "credential": None, "credential_returned_once": False, "replayed": True,
  455. }
  456. with pytest.raises(EdgeGatewayConflictError):
  457. service.rotate(
  458. gateway["gateway_id"], certificate_sha256="d" * 64,
  459. request_id="rotation-request-1", actor_uid=rotation_actor,
  460. )
  461. with pytest.raises(EdgeGatewayAuthenticationError):
  462. service.authenticate(
  463. credential=gateway["credential"], certificate_sha256="b" * 64,
  464. gateway_id=gateway["gateway_id"], environment="staging",
  465. network_zone="manufacturing-zone-a", generation=1,
  466. )
  467. assert service.heartbeat(
  468. credential=rotated["credential"], certificate_sha256="c" * 64,
  469. gateway_id=gateway["gateway_id"], environment="staging",
  470. network_zone="manufacturing-zone-a", generation=2, version="3.0.1",
  471. safe_summary={"queue_depth": 1},
  472. )["status"] == "online"
  473. assert service.revoke(gateway["gateway_id"], actor_uid=_uid()) is True
  474. with pytest.raises(DBAPIError), engine.begin() as connection:
  475. connection.execute(text("UPDATE edge_gateways SET status='active' WHERE uid=CAST(:gateway AS uuid)"), {"gateway": gateway["gateway_id"]})
  476. with pytest.raises(EdgeGatewayAuthenticationError):
  477. service.authenticate(
  478. credential=rotated["credential"], certificate_sha256="c" * 64,
  479. gateway_id=gateway["gateway_id"], environment="staging",
  480. network_zone="manufacturing-zone-a", generation=2,
  481. )
  482. with engine.connect() as connection:
  483. assert "untrusted_request_rejected" in set(connection.execute(text(
  484. "SELECT event_type FROM edge_gateway_audits WHERE success=false"
  485. )).scalars())
  486. def test_database_rejects_second_active_credential_combination_injection(service, engine):
  487. gateway = _register(service, _enrollment(service))
  488. with pytest.raises(DBAPIError), engine.begin() as connection:
  489. connection.execute(text("""
  490. INSERT INTO edge_gateway_credentials
  491. (uid,gateway_id,generation,credential_hash,certificate_sha256,status,expires_at)
  492. VALUES (CAST(:uid AS uuid),CAST(:gateway AS uuid),2,:credential,:certificate,
  493. 'active',CURRENT_TIMESTAMP+INTERVAL '365 days');
  494. UPDATE edge_gateways SET current_generation=2 WHERE uid=CAST(:gateway AS uuid)
  495. """), {
  496. "uid": _uid(), "gateway": gateway["gateway_id"],
  497. "credential": "9" * 64, "certificate": "8" * 64,
  498. })
  499. with engine.connect() as connection:
  500. row = connection.execute(text("""
  501. SELECT g.current_generation,count(*) FILTER (WHERE c.status='active') AS active_count
  502. FROM edge_gateways g JOIN edge_gateway_credentials c ON c.gateway_id=g.uid
  503. GROUP BY g.current_generation
  504. """)).mappings().one()
  505. assert row == {"current_generation": 1, "active_count": 1}
  506. def test_failure_evidence_tables_reject_direct_mutation_and_truncation(service, engine):
  507. with pytest.raises(EdgeGatewayAuthenticationError):
  508. service.authenticate(
  509. credential="dopg_invalid", certificate_sha256="b" * 64,
  510. gateway_id=_uid(), environment="staging",
  511. network_zone="zone-a", generation=1,
  512. )
  513. with pytest.raises(DBAPIError), engine.begin() as connection:
  514. connection.execute(text("TRUNCATE TABLE edge_gateway_audits"))
  515. for statement in (
  516. "UPDATE edge_gateway_failure_windows SET occurrence_count=occurrence_count+1,last_seen_at=CURRENT_TIMESTAMP",
  517. "UPDATE edge_gateway_failure_windows SET occurrence_count=occurrence_count+5",
  518. "UPDATE edge_gateway_failure_windows SET last_seen_at=last_seen_at-INTERVAL '1 second'",
  519. "UPDATE edge_gateway_failure_windows SET first_seen_at=first_seen_at+INTERVAL '1 second'",
  520. "UPDATE edge_gateway_failure_windows SET failure_fingerprint=:fingerprint",
  521. "DELETE FROM edge_gateway_failure_windows",
  522. "TRUNCATE TABLE edge_gateway_failure_windows",
  523. ):
  524. with pytest.raises(DBAPIError), engine.begin() as connection:
  525. connection.execute(text(statement), {"fingerprint": "f" * 64})
  526. with engine.connect() as connection:
  527. evidence = connection.execute(text("""
  528. SELECT occurrence_count,first_seen_at<=last_seen_at AS monotonic
  529. FROM edge_gateway_failure_windows
  530. """)).one()
  531. assert evidence == (1, True)
  532. assert connection.execute(text("SELECT count(*) FROM edge_gateway_audits")).scalar_one() == 1
  533. def test_task_pull_cancel_event_idempotency_policy_and_release(service, engine):
  534. gateway = _register(service, _enrollment(service))
  535. auth = {
  536. "credential": gateway["credential"], "certificate_sha256": "b" * 64,
  537. "gateway_id": gateway["gateway_id"], "environment": "staging",
  538. "network_zone": "manufacturing-zone-a", "generation": 1,
  539. }
  540. task = service.issue_task({
  541. "task_id": "task-1", "gateway_id": gateway["gateway_id"],
  542. "environment": "staging", "network_zone": "manufacturing-zone-a",
  543. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  544. "contract_version": 1,
  545. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  546. "attempt": 1, "idempotency_key": "task-key-1", "policy_digest": "a" * 64,
  547. }, actor_uid=_uid())
  548. conflicting_task = {
  549. "task_id": "task-conflicting", "gateway_id": gateway["gateway_id"],
  550. "environment": "staging", "network_zone": "manufacturing-zone-a",
  551. "purpose": "different-purpose", "classification": "statistics", "task_type": "profile",
  552. "contract_version": 1,
  553. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  554. "attempt": 1, "idempotency_key": "task-key-1", "policy_digest": "a" * 64,
  555. }
  556. with pytest.raises(EdgeGatewayConflictError):
  557. service.issue_task(conflicting_task, actor_uid=_uid())
  558. with pytest.raises(EdgeGatewayValidationError):
  559. service.issue_task({
  560. "task_id": "task-wrong-binding", "gateway_id": gateway["gateway_id"],
  561. "environment": "production", "network_zone": "manufacturing-zone-a",
  562. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  563. "contract_version": 1,
  564. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  565. "attempt": 1, "idempotency_key": "task-wrong-binding-key", "policy_digest": "a" * 64,
  566. }, actor_uid=_uid())
  567. pulled = service.pull_task(**auth)
  568. assert pulled["task"]["task_id"] == task["task_id"]
  569. approved = _event(task, gateway)
  570. with pytest.raises(EdgeGatewayAuthenticationError):
  571. service.accept_event(event=approved, lease_token="dopl_wrong", **auth)
  572. ack1 = service.accept_event(event=approved, lease_token=pulled["lease_token"], **auth)
  573. ack2 = service.accept_event(event=approved, lease_token=pulled["lease_token"], **auth)
  574. assert ack1 == ack2
  575. assert set(ack1) == {
  576. "event_id", "event_digest", "remote_lease_digest", "received_at", "status",
  577. }
  578. assert ack1["event_digest"] == EdgeEventContract.from_mapping(approved).digest
  579. assert ack1["remote_lease_digest"] == hashlib.sha256(
  580. pulled["lease_token"].encode("utf-8")
  581. ).hexdigest()
  582. assert pulled["lease_token"] not in str(ack1)
  583. changed = dict(approved)
  584. changed["payload"] = {"asset_count": 13}
  585. with pytest.raises((EdgeGatewayConflictError, EdgeGatewayValidationError)):
  586. service.accept_event(event=changed, **auth)
  587. raw = dict(approved)
  588. raw.update(classification="raw", payload={"password": "super-secret-value"}, event_id="evt_" + "0" * 64)
  589. with pytest.raises(EdgeGatewayValidationError):
  590. service.accept_event(event=raw, **auth)
  591. wrong_purpose = dict(approved)
  592. wrong_purpose["purpose"] = "different-purpose"
  593. identity = {key: value for key, value in wrong_purpose.items() if key != "event_id"}
  594. wrong_purpose["event_id"] = stable_event_id(identity)
  595. with pytest.raises((EdgeGatewayValidationError, EdgeGatewayAuthenticationError)):
  596. service.accept_event(event=wrong_purpose, lease_token=pulled["lease_token"], **auth)
  597. assert service.cancel_task(task["task_id"], actor_uid=_uid()) is False
  598. assert service.reconcile(**auth)["cancelled_task_ids"] == []
  599. assert service.accept_event(
  600. event=approved, lease_token=pulled["lease_token"], **auth,
  601. ) == ack1
  602. with engine.connect() as connection:
  603. assert connection.execute(text("SELECT count(*) FROM edge_gateway_events")).scalar_one() == 1
  604. release = service.offer_release(
  605. gateway["gateway_id"], version="3.1.0", artifact_digest="d" * 64,
  606. rollback_version="3.0.0", request_id="release-request-1", actor_uid=_uid(),
  607. )
  608. assert service.offer_release(
  609. gateway["gateway_id"], version="3.1.0", artifact_digest="d" * 64,
  610. rollback_version="3.0.0", request_id="release-request-1", actor_uid=_uid(),
  611. ) == release
  612. with pytest.raises(EdgeGatewayConflictError):
  613. service.offer_release(
  614. gateway["gateway_id"], version="3.1.1", artifact_digest="d" * 64,
  615. rollback_version="3.0.0", request_id="release-request-1", actor_uid=_uid(),
  616. )
  617. with pytest.raises(EdgeGatewayConflictError):
  618. service.acknowledge_release(
  619. release_id=release["release_id"], outcome="rollback",
  620. safe_summary={"reason_code": "health_check_failed"}, **auth,
  621. )
  622. accepted = service.acknowledge_release(
  623. release_id=release["release_id"], outcome="accepted",
  624. safe_summary={"reason_code": "accepted"}, **auth,
  625. )
  626. pending = service.reconcile(**auth)["release_offers"]
  627. assert [(item["release_id"], item["status"]) for item in pending] == [(release["release_id"], "accepted")]
  628. assert service.acknowledge_release(
  629. release_id=release["release_id"], outcome="accepted",
  630. safe_summary={"reason_code": "accepted"}, **auth,
  631. ) == accepted
  632. service.acknowledge_release(
  633. release_id=release["release_id"], outcome="failed",
  634. safe_summary={"reason_code": "health_check_failed"}, **auth,
  635. )
  636. acked = service.acknowledge_release(
  637. release_id=release["release_id"], outcome="rollback",
  638. safe_summary={"reason_code": "health_check_failed"}, **auth,
  639. )
  640. assert acked["status"] == "rolled_back"
  641. installed_release = service.offer_release(
  642. gateway["gateway_id"], version="3.2.0", artifact_digest="e" * 64,
  643. rollback_version="3.1.0", request_id="release-request-2", actor_uid=_uid(),
  644. )
  645. service.acknowledge_release(
  646. release_id=installed_release["release_id"], outcome="accepted",
  647. safe_summary={"reason_code": "accepted"}, **auth,
  648. )
  649. service.acknowledge_release(
  650. release_id=installed_release["release_id"], outcome="installed",
  651. safe_summary={"reason_code": "installed"}, **auth,
  652. )
  653. installed_rollback = service.acknowledge_release(
  654. release_id=installed_release["release_id"], outcome="rollback",
  655. safe_summary={"reason_code": "post_install_health_failed"}, **auth,
  656. )
  657. assert installed_rollback["status"] == "rolled_back"
  658. assert service.acknowledge_release(
  659. release_id=installed_release["release_id"], outcome="rollback",
  660. safe_summary={"reason_code": "post_install_health_failed"}, **auth,
  661. ) == installed_rollback
  662. with engine.connect() as connection:
  663. audit_rows = connection.execute(text("SELECT event_type,safe_detail FROM edge_gateway_audits")).mappings().all()
  664. details = [row["safe_detail"] for row in audit_rows]
  665. assert all("credential" not in str(detail).lower() and "token" not in str(detail).lower() for detail in details)
  666. assert all("super-secret-value" not in str(detail) for detail in details)
  667. assert {"idempotency_conflict", "binding_rejected", "policy_rejected", "release_rejected"} <= {
  668. row["event_type"] for row in audit_rows
  669. }
  670. with pytest.raises(DBAPIError), engine.begin() as connection:
  671. connection.execute(text("UPDATE edge_gateway_audits SET success=true"))
  672. with pytest.raises(DBAPIError), engine.begin() as connection:
  673. connection.execute(text("DELETE FROM edge_gateway_audits"))
  674. task_failed = service.issue_task({
  675. "task_id": "task-failed", "gateway_id": gateway["gateway_id"],
  676. "environment": "staging", "network_zone": "manufacturing-zone-a",
  677. "purpose": "inventory", "classification": "statistics", "task_type": "quality",
  678. "contract_version": 1,
  679. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  680. "attempt": 1, "idempotency_key": "task-failed-key", "policy_digest": "a" * 64,
  681. }, actor_uid=_uid())
  682. failed_lease = service.pull_task(**auth)
  683. failed = service.task_outcome(
  684. task_failed["task_id"], outcome="failed", lease_token=failed_lease["lease_token"],
  685. safe_summary={"reason_code": "controlled_failure"}, **auth,
  686. )
  687. assert failed["status"] == "failed"
  688. assert service.task_outcome(
  689. task_failed["task_id"], outcome="failed", lease_token=failed_lease["lease_token"],
  690. safe_summary={"reason_code": "controlled_failure"}, **auth,
  691. )["replayed"] is True
  692. task_cancel = service.issue_task({
  693. "task_id": "task-cancelled", "gateway_id": gateway["gateway_id"],
  694. "environment": "staging", "network_zone": "manufacturing-zone-a",
  695. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  696. "contract_version": 1,
  697. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  698. "attempt": 1, "idempotency_key": "task-cancelled-key", "policy_digest": "a" * 64,
  699. }, actor_uid=_uid())
  700. cancel_lease = service.pull_task(**auth)
  701. assert service.cancel_task(task_cancel["task_id"], actor_uid=_uid()) is True
  702. assert service.task_outcome(
  703. task_cancel["task_id"], outcome="cancelled", lease_token=cancel_lease["lease_token"],
  704. safe_summary={"reason_code": "cancel_acknowledged"}, **auth,
  705. )["status"] == "cancelled"
  706. def test_control_plane_signs_task_authority_and_complete_release_state(service, engine):
  707. gateway = _register(service, _enrollment(service))
  708. auth = {
  709. "credential": gateway["credential"],
  710. "certificate_sha256": "b" * 64,
  711. "gateway_id": gateway["gateway_id"],
  712. "environment": "staging",
  713. "network_zone": "manufacturing-zone-a",
  714. "generation": 1,
  715. }
  716. deadline = (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z")
  717. task = service.issue_task({
  718. "task_id": "task-signed-authority", "gateway_id": gateway["gateway_id"],
  719. "environment": "staging", "network_zone": "manufacturing-zone-a",
  720. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  721. "contract_version": 1, "deadline_at": deadline, "attempt": 1,
  722. "idempotency_key": "task-signed-authority-key", "policy_digest": "a" * 64,
  723. }, actor_uid=_uid())
  724. pulled = service.pull_task(**auth)
  725. envelope = pulled["signed_task_envelope"]
  726. assert pulled["task"] == envelope["task"]
  727. assert envelope["task"]["task_id"] == task["task_id"]
  728. assert set(envelope) == {
  729. "task", "authority_key_id", "signature_algorithm", "contract_digest",
  730. "gateway_id", "environment",
  731. "network_zone", "policy_digest", "purpose", "issued_at", "expires_at",
  732. "signature",
  733. }
  734. assert envelope["signature_algorithm"] == "Ed25519"
  735. assert envelope["authority_key_id"] == _SIGNING_KEY_ID
  736. assert envelope["contract_digest"] == canonical_sha256(envelope["task"])
  737. unsigned_authority = {key: value for key, value in envelope.items() if key != "signature"}
  738. _SIGNING_PRIVATE_KEY.public_key().verify(
  739. bytes.fromhex(envelope["signature"]),
  740. SignedTaskEnvelope.canonical_unsigned_bytes(unsigned_authority),
  741. )
  742. release_deadline = (datetime.now(UTC) + timedelta(hours=1)).isoformat().replace("+00:00", "Z")
  743. release = service.offer_release(
  744. gateway["gateway_id"], version="6.0.0", artifact_digest="6" * 64,
  745. artifact_name="edge-agent-6.0.0.bin", deadline_at=release_deadline,
  746. rollback_version="3.0.0", request_id="signed-release-1", actor_uid=_uid(),
  747. )
  748. manifest = release["manifest"]
  749. with pytest.raises(EdgeGatewayValidationError):
  750. service.offer_release(
  751. gateway["gateway_id"], version="release-six", artifact_digest="6" * 64,
  752. rollback_version="3.0.0", request_id="invalid-release-version",
  753. actor_uid=_uid(),
  754. )
  755. with pytest.raises(EdgeGatewayValidationError):
  756. service.offer_release(
  757. gateway["gateway_id"], version="2.9.0", artifact_digest="6" * 64,
  758. rollback_version="3.0.0", request_id="release-downgrade",
  759. actor_uid=_uid(),
  760. )
  761. assert set(manifest) == {
  762. "release_id", "version", "rollback_version", "artifact_digest", "artifact_name",
  763. "deadline_at", "signature_algorithm", "key_id", "manifest_digest", "signature",
  764. "status",
  765. }
  766. assert manifest["status"] == "offered"
  767. unsigned_manifest = {
  768. key: value for key, value in manifest.items()
  769. if key not in {"manifest_digest", "signature"}
  770. }
  771. assert manifest["manifest_digest"] == canonical_sha256(unsigned_manifest)
  772. _SIGNING_PRIVATE_KEY.public_key().verify(
  773. bytes.fromhex(manifest["signature"]), canonical_json_bytes(unsigned_manifest),
  774. )
  775. for outcome, expected in (("accepted", "accepted"), ("failed", "failed"), ("rollback", "rolled_back")):
  776. service.acknowledge_release(
  777. release_id=release["release_id"], outcome=outcome,
  778. safe_summary={"release_status": expected, "version": "3.0.0"}, **auth,
  779. )
  780. response = service.reconcile(**auth)
  781. candidates = list(response["release_offers"])
  782. if response["release_baseline"] is not None:
  783. candidates.append(response["release_baseline"])
  784. reconciled = {
  785. item["release_id"]: item for item in candidates
  786. }[release["release_id"]]
  787. assert reconciled["status"] == expected
  788. unsigned = {
  789. key: value for key, value in reconciled.items()
  790. if key not in {"manifest_digest", "signature"}
  791. }
  792. assert reconciled["manifest_digest"] == canonical_sha256(unsigned)
  793. _SIGNING_PRIVATE_KEY.public_key().verify(
  794. bytes.fromhex(reconciled["signature"]), canonical_json_bytes(unsigned),
  795. )
  796. with engine.connect() as connection:
  797. task_row = connection.execute(text("""
  798. SELECT authority,contract FROM edge_gateway_tasks WHERE task_id='task-signed-authority'
  799. """)).mappings().one()
  800. release_row = connection.execute(text("""
  801. SELECT signed_manifest FROM edge_gateway_releases WHERE uid=CAST(:uid AS uuid)
  802. """), {"uid": release["release_id"]}).scalar_one()
  803. assert dict(task_row["authority"]) == envelope
  804. assert dict(release_row)["status"] == "rolled_back"
  805. persisted = f"{task_row['authority']}{release_row}"
  806. assert "PRIVATE KEY" not in persisted and "signing_private_key" not in persisted
  807. @pytest.mark.parametrize("seconds", [30, 119, 120])
  808. def test_pull_lease_never_extends_beyond_task_deadline(service, seconds):
  809. gateway = _register(service, _enrollment(service))
  810. auth = {
  811. "credential": gateway["credential"],
  812. "certificate_sha256": "b" * 64,
  813. "gateway_id": gateway["gateway_id"],
  814. "environment": "staging",
  815. "network_zone": "manufacturing-zone-a",
  816. "generation": 1,
  817. }
  818. deadline = (datetime.now(UTC) + timedelta(seconds=seconds)).isoformat().replace(
  819. "+00:00", "Z"
  820. )
  821. service.issue_task(
  822. {
  823. "task_id": f"deadline-boundary-{seconds}",
  824. "gateway_id": gateway["gateway_id"],
  825. "environment": "staging",
  826. "network_zone": "manufacturing-zone-a",
  827. "purpose": "inventory",
  828. "classification": "statistics",
  829. "task_type": "profile",
  830. "contract_version": 1,
  831. "deadline_at": deadline,
  832. "attempt": 1,
  833. "idempotency_key": f"deadline-boundary-{seconds}-key",
  834. "policy_digest": "a" * 64,
  835. },
  836. actor_uid=_uid(),
  837. )
  838. pulled = service.pull_task(**auth)
  839. assert datetime.fromisoformat(pulled["lease_expires_at"]) <= datetime.fromisoformat(
  840. deadline.replace("Z", "+00:00")
  841. )
  842. def test_cancel_reconcile_uses_stable_cursor_so_more_than_fifty_never_starve(
  843. service,
  844. ):
  845. gateway = _register(service, _enrollment(service))
  846. auth = {
  847. "credential": gateway["credential"],
  848. "certificate_sha256": "b" * 64,
  849. "gateway_id": gateway["gateway_id"],
  850. "environment": "staging",
  851. "network_zone": "manufacturing-zone-a",
  852. "generation": 1,
  853. }
  854. expected = []
  855. deadline = (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace(
  856. "+00:00", "Z"
  857. )
  858. for index in range(52):
  859. task_id = f"cancel-page-{index:02d}"
  860. service.issue_task(
  861. {
  862. "task_id": task_id,
  863. "gateway_id": gateway["gateway_id"],
  864. "environment": "staging",
  865. "network_zone": "manufacturing-zone-a",
  866. "purpose": "inventory",
  867. "classification": "statistics",
  868. "task_type": "profile",
  869. "contract_version": 1,
  870. "deadline_at": deadline,
  871. "attempt": 1,
  872. "idempotency_key": f"cancel-page-key-{index:02d}",
  873. "policy_digest": "a" * 64,
  874. },
  875. actor_uid=_uid(),
  876. )
  877. assert service.pull_task(**auth)["task"]["task_id"] == task_id
  878. assert service.cancel_task(task_id, actor_uid=_uid()) is True
  879. expected.append(task_id)
  880. first = service.reconcile(limit=50, cancel_cursor=None, release_cursor=None, **auth)
  881. assert first["cancelled_task_ids"] == expected[:50]
  882. assert first["cancel_next_cursor"] == expected[49]
  883. second = service.reconcile(
  884. limit=50,
  885. cancel_cursor=first["cancel_next_cursor"],
  886. release_cursor=None,
  887. **auth,
  888. )
  889. assert second["cancelled_task_ids"] == expected[50:]
  890. assert second["cancel_next_cursor"] is None
  891. def test_pull_terminalizes_expired_task_without_issuing_lease(service, engine):
  892. gateway = _register(service, _enrollment(service))
  893. auth = {
  894. "credential": gateway["credential"],
  895. "certificate_sha256": "b" * 64,
  896. "gateway_id": gateway["gateway_id"],
  897. "environment": "staging",
  898. "network_zone": "manufacturing-zone-a",
  899. "generation": 1,
  900. }
  901. task_id = "already-expired-task"
  902. service.issue_task(
  903. {
  904. "task_id": task_id,
  905. "gateway_id": gateway["gateway_id"],
  906. "environment": "staging",
  907. "network_zone": "manufacturing-zone-a",
  908. "purpose": "inventory",
  909. "classification": "statistics",
  910. "task_type": "profile",
  911. "contract_version": 1,
  912. "deadline_at": (datetime.now(UTC) + timedelta(milliseconds=200))
  913. .isoformat()
  914. .replace("+00:00", "Z"),
  915. "attempt": 1,
  916. "idempotency_key": "already-expired-task-key",
  917. "policy_digest": "a" * 64,
  918. },
  919. actor_uid=_uid(),
  920. )
  921. with engine.connect() as connection:
  922. connection.execute(text("SELECT pg_sleep(0.3)"))
  923. assert service.pull_task(**auth) == {"task": None}
  924. with engine.connect() as connection:
  925. row = connection.execute(
  926. text(
  927. "SELECT status,lease_token_hash,lease_expires_at,result_summary "
  928. "FROM edge_gateway_tasks WHERE task_id=:task_id"
  929. ),
  930. {"task_id": task_id},
  931. ).mappings().one()
  932. assert row["status"] == "failed"
  933. assert row["lease_token_hash"] is None and row["lease_expires_at"] is None
  934. assert dict(row["result_summary"]) == {"reason_code": "task_deadline_expired"}
  935. def test_reconcile_prioritizes_new_actionable_release_over_terminal_history(service):
  936. gateway = _register(service, _enrollment(service))
  937. auth = {
  938. "credential": gateway["credential"],
  939. "certificate_sha256": "b" * 64,
  940. "gateway_id": gateway["gateway_id"],
  941. "environment": "staging",
  942. "network_zone": "manufacturing-zone-a",
  943. "generation": 1,
  944. }
  945. for index in range(51):
  946. release = service.offer_release(
  947. gateway["gateway_id"],
  948. version=f"4.{index}.0",
  949. rollback_version="3.0.0",
  950. artifact_digest=f"{index:064x}",
  951. request_id=f"terminal-release-{index}",
  952. actor_uid=_uid(),
  953. )
  954. service.acknowledge_release(
  955. release_id=release["release_id"],
  956. outcome="accepted",
  957. safe_summary={"release_status": "accepted", "version": f"4.{index}.0"},
  958. **auth,
  959. )
  960. service.acknowledge_release(
  961. release_id=release["release_id"],
  962. outcome="installed",
  963. safe_summary={"release_status": "installed", "version": f"4.{index}.0"},
  964. **auth,
  965. )
  966. actionable = service.offer_release(
  967. gateway["gateway_id"],
  968. version="9.0.0",
  969. rollback_version="3.0.0",
  970. artifact_digest="f" * 64,
  971. request_id="new-actionable-release",
  972. actor_uid=_uid(),
  973. )
  974. reconciled = service.reconcile(limit=50, **auth)
  975. releases = reconciled["release_offers"]
  976. assert len(releases) == 1
  977. assert releases[0]["release_id"] == actionable["release_id"]
  978. assert releases[0]["status"] == "offered"
  979. assert reconciled["release_next_cursor"] is None
  980. assert reconciled["release_baseline"]["status"] == "installed"
  981. assert reconciled["release_baseline"]["version"] == "4.50.0"
  982. def test_signed_release_offer_installs_through_real_postgres_control_plane(
  983. service, engine, tmp_path,
  984. ):
  985. gateway = _register(service, _enrollment(service))
  986. auth = {
  987. "credential": gateway["credential"],
  988. "certificate_sha256": "b" * 64,
  989. "gateway_id": gateway["gateway_id"],
  990. "environment": "staging",
  991. "network_zone": "manufacturing-zone-a",
  992. "generation": 1,
  993. }
  994. artifact = b"postgres-backed-signed-edge-release"
  995. offered = service.offer_release(
  996. gateway["gateway_id"],
  997. version="3.1.0",
  998. rollback_version="3.0.0",
  999. artifact_digest=hashlib.sha256(artifact).hexdigest(),
  1000. artifact_name="edge-agent-3.1.0.bin",
  1001. deadline_at=(datetime.now(UTC) + timedelta(hours=1))
  1002. .isoformat()
  1003. .replace("+00:00", "Z"),
  1004. request_id="postgres-agent-release-chain",
  1005. actor_uid=_uid(),
  1006. )
  1007. class PostgresControlTransport:
  1008. def reconcile(self):
  1009. return service.reconcile(**auth)
  1010. def acknowledge_release(self, release_id, outcome, summary):
  1011. return service.acknowledge_release(
  1012. release_id=release_id,
  1013. outcome=outcome,
  1014. safe_summary=summary,
  1015. **auth,
  1016. )
  1017. def pull_task(self):
  1018. return None
  1019. public_key = _SIGNING_PRIVATE_KEY.public_key().public_bytes(
  1020. encoding=serialization.Encoding.Raw,
  1021. format=serialization.PublicFormat.Raw,
  1022. ).hex()
  1023. activated = []
  1024. agent = EdgeAgent(
  1025. EdgeBootstrapConfig(
  1026. queue_path=str(tmp_path / "postgres-release-agent.sqlite3"),
  1027. artifact_root=str(tmp_path),
  1028. gateway_id=gateway["gateway_id"],
  1029. credential=gateway["credential"],
  1030. certificate_sha256="b" * 64,
  1031. client_certificate_path=str(tmp_path / "client.pem"),
  1032. client_private_key_path=str(tmp_path / "client.key"),
  1033. ca_bundle_path=str(tmp_path / "ca.pem"),
  1034. generation=1,
  1035. environment="staging",
  1036. network_zone="manufacturing-zone-a",
  1037. policy_digest="a" * 64,
  1038. control_url="https://control.example.test",
  1039. proxy_url=None,
  1040. allowed_control_hosts=frozenset({"control.example.test"}),
  1041. allowed_proxy_hosts=frozenset(),
  1042. trusted_release_keys={_SIGNING_KEY_ID: public_key},
  1043. trusted_task_keys={_SIGNING_KEY_ID: public_key},
  1044. version="3.0.0",
  1045. ),
  1046. PostgresControlTransport(),
  1047. EdgeRunnerAdapter(
  1048. lambda _node, _request, _cancel: {
  1049. "classification": "statistics",
  1050. "payload": {"metric_count": 1},
  1051. }
  1052. ),
  1053. release_manager=SignedReleaseManager(
  1054. current_version="3.0.0",
  1055. trusted_keys={_SIGNING_KEY_ID: public_key},
  1056. artifact_loader=lambda _manifest: artifact,
  1057. activator=lambda version, body: activated.append((version, body)) or True,
  1058. rollback=lambda _version: False,
  1059. ),
  1060. )
  1061. agent.run_once()
  1062. with engine.connect() as connection:
  1063. persisted = connection.execute(
  1064. text("SELECT status,signed_manifest FROM edge_gateway_releases WHERE uid=CAST(:uid AS uuid)"),
  1065. {"uid": offered["release_id"]},
  1066. ).mappings().one()
  1067. assert persisted["status"] == "installed"
  1068. assert dict(persisted["signed_manifest"])["status"] == "installed"
  1069. assert activated == [("3.1.0", artifact)]
  1070. assert agent.queue.get_release_state(offered["release_id"]).status == "installed"
  1071. def test_signing_operations_fail_closed_without_configured_private_key(service, engine):
  1072. gateway = _register(service, _enrollment(service))
  1073. session = Session(engine)
  1074. unsigned_service = EdgeGatewayService(EdgeGatewayRepository(session))
  1075. try:
  1076. with pytest.raises(EdgeGatewayConfigurationError):
  1077. unsigned_service.issue_task({
  1078. "task_id": "task-no-signer", "gateway_id": gateway["gateway_id"],
  1079. "environment": "staging", "network_zone": "manufacturing-zone-a",
  1080. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  1081. "contract_version": 1,
  1082. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1083. "attempt": 1, "idempotency_key": "task-no-signer-key", "policy_digest": "a" * 64,
  1084. }, actor_uid=_uid())
  1085. finally:
  1086. session.close()
  1087. with pytest.raises(EdgeGatewayValidationError):
  1088. service.issue_task({
  1089. "task_id": "task-expired-authority", "gateway_id": gateway["gateway_id"],
  1090. "environment": "staging", "network_zone": "manufacturing-zone-a",
  1091. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  1092. "contract_version": 1,
  1093. "deadline_at": (datetime.now(UTC) - timedelta(seconds=1)).isoformat().replace("+00:00", "Z"),
  1094. "attempt": 1, "idempotency_key": "task-expired-authority-key", "policy_digest": "a" * 64,
  1095. }, actor_uid=_uid())
  1096. def test_task_outcome_audit_is_atomic_bounded_and_replay_idempotent(service, engine):
  1097. gateway = _register(service, _enrollment(service))
  1098. auth = {"credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1099. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1100. "network_zone": "manufacturing-zone-a", "generation": 1}
  1101. task = service.issue_task({
  1102. "task_id": "task-outcome-audit", "gateway_id": gateway["gateway_id"],
  1103. "environment": "staging", "network_zone": "manufacturing-zone-a",
  1104. "purpose": "inventory", "classification": "statistics", "task_type": "quality",
  1105. "contract_version": 1,
  1106. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1107. "attempt": 1, "idempotency_key": "task-outcome-audit-key", "policy_digest": "a" * 64,
  1108. }, actor_uid=_uid())
  1109. lease = service.pull_task(**auth)
  1110. with pytest.raises(EdgeGatewayValidationError):
  1111. service.task_outcome(
  1112. task["task_id"], outcome="failed", lease_token=lease["lease_token"],
  1113. safe_summary={"reason_code": "x" * 40_000}, **auth,
  1114. )
  1115. with engine.begin() as connection:
  1116. connection.execute(text("""
  1117. CREATE OR REPLACE FUNCTION test_reject_task_outcome_audit()
  1118. RETURNS trigger LANGUAGE plpgsql AS $$
  1119. BEGIN
  1120. IF NEW.event_type='task_outcome_recorded' THEN
  1121. RAISE EXCEPTION 'test audit failure';
  1122. END IF;
  1123. RETURN NEW;
  1124. END $$;
  1125. CREATE TRIGGER test_reject_task_outcome_audit
  1126. BEFORE INSERT ON edge_gateway_audits FOR EACH ROW
  1127. EXECUTE FUNCTION test_reject_task_outcome_audit();
  1128. """))
  1129. session_usable = False
  1130. try:
  1131. with pytest.raises(DBAPIError):
  1132. service.task_outcome(
  1133. task["task_id"], outcome="failed", lease_token=lease["lease_token"],
  1134. safe_summary={"reason_code": "controlled_failure"}, **auth,
  1135. )
  1136. service.repository.session.execute(text("SELECT 1")).scalar_one()
  1137. session_usable = True
  1138. finally:
  1139. service.repository.rollback()
  1140. with engine.begin() as connection:
  1141. connection.execute(text("DROP TRIGGER test_reject_task_outcome_audit ON edge_gateway_audits"))
  1142. connection.execute(text("DROP FUNCTION test_reject_task_outcome_audit()"))
  1143. assert session_usable is True
  1144. with engine.connect() as connection:
  1145. assert connection.execute(text("""
  1146. SELECT status FROM edge_gateway_tasks WHERE task_id=:task_id
  1147. """), {"task_id": task["task_id"]}).scalar_one() == "leased"
  1148. assert connection.execute(text("""
  1149. SELECT count(*) FROM edge_gateway_audits WHERE event_type='task_outcome_recorded'
  1150. """)).scalar_one() == 0
  1151. result = service.task_outcome(
  1152. task["task_id"], outcome="failed", lease_token=lease["lease_token"],
  1153. safe_summary={"reason_code": "controlled_failure"}, **auth,
  1154. )
  1155. assert result["status"] == "failed" and result["replayed"] is False
  1156. replay = service.task_outcome(
  1157. task["task_id"], outcome="failed", lease_token=lease["lease_token"],
  1158. safe_summary={"reason_code": "controlled_failure"}, **auth,
  1159. )
  1160. assert replay["status"] == "failed" and replay["replayed"] is True
  1161. with engine.connect() as connection:
  1162. audits = connection.execute(text("""
  1163. SELECT safe_detail FROM edge_gateway_audits WHERE event_type='task_outcome_recorded'
  1164. """)).scalars().all()
  1165. assert audits == [{"task_id": task["task_id"], "outcome": "failed"}]
  1166. def test_cross_gateway_event_replay_never_discloses_foreign_ack(service):
  1167. gateway_a = _register(service, _enrollment(service, gateway_name="gateway-a"))
  1168. gateway_b = _register(service, _enrollment(service, gateway_name="gateway-b"), certificate_sha256="b" * 64)
  1169. auth_a = {"credential": gateway_a["credential"], "certificate_sha256": "b" * 64, "gateway_id": gateway_a["gateway_id"], "environment": "staging", "network_zone": "manufacturing-zone-a", "generation": 1}
  1170. auth_b = {"credential": gateway_b["credential"], "certificate_sha256": "b" * 64, "gateway_id": gateway_b["gateway_id"], "environment": "staging", "network_zone": "manufacturing-zone-a", "generation": 1}
  1171. task = service.issue_task({
  1172. "task_id": "task-owner-a", "gateway_id": gateway_a["gateway_id"], "environment": "staging",
  1173. "network_zone": "manufacturing-zone-a", "purpose": "inventory", "classification": "statistics",
  1174. "task_type": "profile", "contract_version": 1,
  1175. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1176. "attempt": 1, "idempotency_key": "task-owner-a-key", "policy_digest": "a" * 64,
  1177. }, actor_uid=_uid())
  1178. pulled = service.pull_task(**auth_a)
  1179. event = _event(task, gateway_a)
  1180. ack = service.accept_event(event=event, lease_token=pulled["lease_token"], **auth_a)
  1181. with pytest.raises(EdgeGatewayAuthenticationError):
  1182. service.accept_event(event=event, lease_token=pulled["lease_token"], **auth_b)
  1183. assert ack["event_id"] == event["event_id"]
  1184. def test_release_offer_is_concurrently_idempotent_and_conflicts_are_controlled(service, engine):
  1185. gateway = _register(service, _enrollment(service))
  1186. barrier = Barrier(2)
  1187. def offer_once():
  1188. session = Session(engine)
  1189. try:
  1190. barrier.wait(timeout=5)
  1191. return _service(EdgeGatewayRepository(session)).offer_release(
  1192. gateway["gateway_id"], version="5.0.0", artifact_digest="5" * 64,
  1193. rollback_version="4.9.0", request_id="concurrent-release", actor_uid=_uid(),
  1194. )
  1195. finally:
  1196. session.close()
  1197. with ThreadPoolExecutor(max_workers=2) as pool:
  1198. results = [future.result() for future in (pool.submit(offer_once), pool.submit(offer_once))]
  1199. assert results[0] == results[1]
  1200. with pytest.raises(EdgeGatewayConflictError):
  1201. service.offer_release(
  1202. gateway["gateway_id"], version="5.0.1", artifact_digest="6" * 64,
  1203. rollback_version="4.9.0", request_id="concurrent-release", actor_uid=_uid(),
  1204. )
  1205. with pytest.raises(EdgeGatewayConflictError):
  1206. service.offer_release(
  1207. gateway["gateway_id"], version="5.0.0", artifact_digest="5" * 64,
  1208. rollback_version="4.9.0", request_id="different-request", actor_uid=_uid(),
  1209. )
  1210. service.offer_release(
  1211. gateway["gateway_id"], version="5.1.0", artifact_digest="7" * 64,
  1212. rollback_version="5.0.0", request_id="release-b", actor_uid=_uid(),
  1213. )
  1214. with pytest.raises(EdgeGatewayConflictError):
  1215. service.offer_release(
  1216. gateway["gateway_id"], version="5.1.0", artifact_digest="7" * 64,
  1217. rollback_version="5.0.0", request_id="concurrent-release", actor_uid=_uid(),
  1218. )
  1219. assert service.list_gateways()[0]["gateway_id"] == gateway["gateway_id"]
  1220. def test_evidence_owner_and_runtime_role_are_really_isolated(engine):
  1221. runtime_login = f"edge_test_runtime_{uuid.uuid4().hex[:12]}"
  1222. runtime_password = f"runtime-{uuid.uuid4().hex}"
  1223. runtime_engine = None
  1224. runtime_session = None
  1225. with engine.begin() as connection:
  1226. connection.execute(text(f'CREATE ROLE "{runtime_login}" LOGIN PASSWORD :password'), {
  1227. "password": runtime_password,
  1228. })
  1229. connection.execute(text(f'GRANT dataops_app_runtime TO "{runtime_login}"'))
  1230. try:
  1231. runtime_url = make_url(DATABASE_URL).set(
  1232. username=runtime_login, password=runtime_password,
  1233. )
  1234. runtime_engine = create_engine(runtime_url, pool_pre_ping=True)
  1235. validate_production_database_identity(
  1236. SimpleNamespace(config={"FLASK_ENV": "production"}), runtime_engine,
  1237. )
  1238. runtime_session = Session(runtime_engine)
  1239. runtime_service = _service(EdgeGatewayRepository(runtime_session))
  1240. enrollment = _enrollment(runtime_service)
  1241. gateway = _register(runtime_service, enrollment)
  1242. assert gateway["generation"] == 1
  1243. auth = {
  1244. "credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1245. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1246. "network_zone": "manufacturing-zone-a", "generation": 1,
  1247. }
  1248. assert runtime_service.heartbeat(
  1249. version="3.0.1", safe_summary={"queue_depth": 0}, **auth,
  1250. )["status"] == "online"
  1251. task = runtime_service.issue_task({
  1252. "task_id": "runtime-role-task", "gateway_id": gateway["gateway_id"],
  1253. "environment": "staging", "network_zone": "manufacturing-zone-a",
  1254. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  1255. "contract_version": 1,
  1256. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1257. "attempt": 1, "idempotency_key": "runtime-role-task-key", "policy_digest": "a" * 64,
  1258. }, actor_uid=_uid())
  1259. assert runtime_service.pull_task(**auth)["task"]["task_id"] == task["task_id"]
  1260. with runtime_engine.connect() as connection:
  1261. identity = connection.execute(text("SELECT current_user,session_user")).one()
  1262. assert identity == (runtime_login, runtime_login)
  1263. owners = dict(connection.execute(text("""
  1264. SELECT c.relname,r.rolname FROM pg_class c
  1265. JOIN pg_roles r ON r.oid=c.relowner
  1266. WHERE c.relname IN ('edge_gateway_audits','edge_gateway_failure_windows')
  1267. """)).all())
  1268. assert owners == {
  1269. "edge_gateway_audits": "dataops_edge_evidence_owner",
  1270. "edge_gateway_failure_windows": "dataops_edge_evidence_owner",
  1271. }
  1272. assert connection.execute(text("""
  1273. SELECT count(*)=0 FROM information_schema.routine_privileges
  1274. WHERE routine_schema='public'
  1275. AND routine_name IN ('record_edge_gateway_failure','record_edge_gateway_audit')
  1276. AND grantee='PUBLIC' AND privilege_type='EXECUTE'
  1277. """)).scalar_one() is True
  1278. attacks = (
  1279. "INSERT INTO edge_gateway_audits(uid,event_type,success,safe_detail) VALUES (gen_random_uuid(),'authentication_rejected',false,'{}')",
  1280. "UPDATE edge_gateway_audits SET success=true",
  1281. "DELETE FROM edge_gateway_failure_windows",
  1282. "TRUNCATE TABLE edge_gateway_audits,edge_gateway_failure_windows",
  1283. "ALTER TABLE edge_gateway_audits DISABLE TRIGGER ALL",
  1284. "SET ROLE dataops_edge_evidence_owner",
  1285. )
  1286. for statement in attacks:
  1287. with pytest.raises(DBAPIError), runtime_engine.begin() as connection:
  1288. connection.execute(text(statement))
  1289. with runtime_engine.begin() as connection:
  1290. connection.execute(text("SELECT set_config('dataops.edge_failure_writer','477-controlled',false)"))
  1291. with pytest.raises(DBAPIError):
  1292. connection.execute(text("""
  1293. INSERT INTO edge_gateway_failure_windows
  1294. (failure_fingerprint,window_started_at,event_type,occurrence_count,first_seen_at,last_seen_at)
  1295. VALUES (:fingerprint,CURRENT_TIMESTAMP,'authentication_rejected',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
  1296. """), {"fingerprint": "f" * 64})
  1297. finally:
  1298. if runtime_session is not None:
  1299. runtime_session.close()
  1300. if runtime_engine is not None:
  1301. runtime_engine.dispose()
  1302. with engine.begin() as connection:
  1303. connection.execute(text(f'DROP ROLE IF EXISTS "{runtime_login}"'))
  1304. def test_production_owner_database_identity_and_direct_entry_fail_closed(engine):
  1305. with pytest.raises(RuntimeError, match="least-privilege runtime identity"):
  1306. validate_production_database_identity(
  1307. SimpleNamespace(config={"FLASK_ENV": "production"}), engine,
  1308. )
  1309. result = subprocess.run(
  1310. [str(ROOT / ".venv/bin/python"), "-c", "from app import create_app; create_app()"],
  1311. cwd=ROOT,
  1312. env={
  1313. **os.environ,
  1314. "PYTHONPATH": str(ROOT),
  1315. "FLASK_ENV": "production",
  1316. "APP_ENV_FILE": "/nonexistent/dataops-test.env",
  1317. "DATABASE_URL": DATABASE_URL,
  1318. "NEO4J_URI": "bolt://neo4j.example.test:7687",
  1319. "NEO4J_HTTP_URI": "http://neo4j.example.test:7474",
  1320. "NEO4J_USER": "dataops_graph",
  1321. "NEO4J_PASSWORD": "graph-test-secret",
  1322. "MINIO_HOST": "minio.example.test:9000",
  1323. "MINIO_USER": "dataops_objects",
  1324. "MINIO_PASSWORD": "object-test-secret",
  1325. "MINIO_BUCKET": "dataops-test",
  1326. },
  1327. capture_output=True,
  1328. text=True,
  1329. )
  1330. assert result.returncode != 0
  1331. assert "least-privilege runtime identity" in result.stderr
  1332. assert "dataops-test-password" not in result.stderr
  1333. def test_preprovisioned_no_createrole_migrator_can_apply_477_and_478(engine):
  1334. migrator = f"edge_test_migrator_{uuid.uuid4().hex[:12]}"
  1335. password = f"migration-{uuid.uuid4().hex}"
  1336. _clear_edge_rows_for_test(engine)
  1337. _alembic("downgrade", "20260802_476")
  1338. try:
  1339. with engine.begin() as connection:
  1340. connection.execute(text(f'CREATE ROLE "{migrator}" LOGIN NOSUPERUSER NOCREATEROLE NOCREATEDB PASSWORD :password'), {"password": password})
  1341. connection.execute(text(f'GRANT dataops_edge_evidence_owner TO "{migrator}"'))
  1342. connection.execute(text('GRANT USAGE,CREATE ON SCHEMA public TO dataops_edge_evidence_owner'))
  1343. connection.execute(text(f'GRANT USAGE,CREATE ON SCHEMA public TO "{migrator}"'))
  1344. connection.execute(text(f'GRANT SELECT,INSERT,UPDATE,DELETE ON alembic_version TO "{migrator}"'))
  1345. migrator_url = make_url(DATABASE_URL).set(
  1346. username=migrator, password=password,
  1347. ).render_as_string(hide_password=False)
  1348. try:
  1349. _alembic("upgrade", "head", migrator_url)
  1350. except subprocess.CalledProcessError as exc:
  1351. pytest.fail(exc.stderr)
  1352. with engine.connect() as connection:
  1353. assert connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() == "20260809_479"
  1354. assert connection.execute(text("SELECT NOT rolcreaterole AND NOT rolsuper FROM pg_roles WHERE rolname=:role"), {"role": migrator}).scalar_one() is True
  1355. finally:
  1356. _clear_edge_rows_for_test(engine)
  1357. _alembic("downgrade", "20260802_476")
  1358. with engine.begin() as connection:
  1359. connection.execute(text(f'REVOKE dataops_edge_evidence_owner FROM "{migrator}"'))
  1360. connection.execute(text(f'DROP OWNED BY "{migrator}"'))
  1361. connection.execute(text(f'DROP ROLE "{migrator}"'))
  1362. _alembic("upgrade", "head")
  1363. def test_backend_runtime_configuration_never_contains_migrator_credentials():
  1364. compose = (ROOT / "deploy/docker/docker-compose.yml").read_text()
  1365. backend_dockerfile = (ROOT / "deploy/docker/backend.Dockerfile").read_text()
  1366. runner_dockerfile = (ROOT / "deploy/docker/runner.Dockerfile").read_text()
  1367. run_script = (ROOT / "scripts/run_dataops.sh").read_text()
  1368. assert "db-role-init:" in compose and "db-migrate:" in compose
  1369. assert "DATAOPS_RUNTIME_PASSWORD" in compose
  1370. assert "postgresql://dataops_app:" in compose
  1371. backend = compose.split(" backend:", 1)[1].split(" frontend:", 1)[0]
  1372. assert "postgresql://dataops:dataops-test-password" not in backend
  1373. assert "MIGRATION_DATABASE_URL" not in backend
  1374. assert "db-migrate:\n condition: service_completed_successfully" in backend
  1375. backend_runtime = backend_dockerfile.split("FROM dependencies AS runtime", 1)[1]
  1376. assert "alembic.ini" not in backend_runtime and "COPY migrations/" not in backend_runtime
  1377. assert "pip uninstall -y alembic" in backend_runtime
  1378. assert "alembic.ini" not in runner_dockerfile and "COPY migrations/" not in runner_dockerfile
  1379. assert "pip uninstall -y alembic" in runner_dockerfile
  1380. assert 'role_init_url="${DB_ROLE_INIT_DATABASE_URL:' in run_script
  1381. assert 'migration_url="${MIGRATION_DATABASE_URL:' in run_script
  1382. assert "unset DB_ROLE_INIT_DATABASE_URL MIGRATION_DATABASE_URL" in run_script
  1383. assert "MIGRATION_DATABASE_URL=" in (ROOT / "deployment/dataops.env").read_text()
  1384. def test_task_pull_and_exact_event_replay_are_concurrency_safe(service, engine):
  1385. gateway = _register(service, _enrollment(service))
  1386. auth = {
  1387. "credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1388. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1389. "network_zone": "manufacturing-zone-a", "generation": 1,
  1390. }
  1391. task = service.issue_task({
  1392. "task_id": "task-concurrent", "gateway_id": gateway["gateway_id"],
  1393. "environment": "staging", "network_zone": "manufacturing-zone-a",
  1394. "purpose": "inventory", "classification": "statistics", "task_type": "profile",
  1395. "contract_version": 1,
  1396. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1397. "attempt": 1, "idempotency_key": "task-concurrent-key", "policy_digest": "a" * 64,
  1398. }, actor_uid=_uid())
  1399. pull_barrier = Barrier(2)
  1400. def pull_once():
  1401. session = Session(engine)
  1402. try:
  1403. worker = _service(EdgeGatewayRepository(session))
  1404. pull_barrier.wait()
  1405. return worker.pull_task(**auth)
  1406. finally:
  1407. session.close()
  1408. with ThreadPoolExecutor(max_workers=2) as pool:
  1409. pulls = [future.result() for future in (pool.submit(pull_once), pool.submit(pull_once))]
  1410. assert sorted(result["task"] is not None for result in pulls) == [False, True]
  1411. lease_token = next(result["lease_token"] for result in pulls if result["task"] is not None)
  1412. event = _event(task, gateway)
  1413. event_barrier = Barrier(2)
  1414. def accept_once():
  1415. session = Session(engine)
  1416. try:
  1417. worker = _service(EdgeGatewayRepository(session))
  1418. event_barrier.wait()
  1419. return worker.accept_event(event=event, lease_token=lease_token, **auth)
  1420. finally:
  1421. session.close()
  1422. with ThreadPoolExecutor(max_workers=2) as pool:
  1423. acks = [future.result() for future in (pool.submit(accept_once), pool.submit(accept_once))]
  1424. assert acks[0] == acks[1]
  1425. with engine.connect() as connection:
  1426. assert connection.execute(text("SELECT count(*) FROM edge_gateway_events WHERE event_id=:event_id"), {"event_id": event["event_id"]}).scalar_one() == 1
  1427. assert service.accept_event(event=event, lease_token=lease_token, **auth) == acks[0]
  1428. def test_cancel_and_event_barrier_has_single_linearized_terminal_state(service, engine):
  1429. gateway = _register(service, _enrollment(service))
  1430. auth = {"credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1431. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1432. "network_zone": "manufacturing-zone-a", "generation": 1}
  1433. task = service.issue_task({
  1434. "task_id": "task-race", "gateway_id": gateway["gateway_id"], "environment": "staging",
  1435. "network_zone": "manufacturing-zone-a", "purpose": "inventory", "classification": "statistics",
  1436. "task_type": "profile", "contract_version": 1,
  1437. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1438. "attempt": 1, "idempotency_key": "task-race-key", "policy_digest": "a" * 64,
  1439. }, actor_uid=_uid())
  1440. lease = service.pull_task(**auth)
  1441. event = _event(task, gateway)
  1442. barrier = Barrier(2)
  1443. def accept():
  1444. session = Session(engine)
  1445. try:
  1446. barrier.wait()
  1447. return _service(EdgeGatewayRepository(session)).accept_event(
  1448. event=event, lease_token=lease["lease_token"], **auth,
  1449. )
  1450. except EdgeGatewayError:
  1451. return None
  1452. finally:
  1453. session.close()
  1454. def cancel():
  1455. session = Session(engine)
  1456. try:
  1457. barrier.wait()
  1458. return _service(EdgeGatewayRepository(session)).cancel_task(task["task_id"], actor_uid=_uid())
  1459. finally:
  1460. session.close()
  1461. with ThreadPoolExecutor(max_workers=2) as pool:
  1462. accepted, cancelled = pool.submit(accept), pool.submit(cancel)
  1463. accepted, cancelled = accepted.result(), cancelled.result()
  1464. with engine.connect() as connection:
  1465. row = connection.execute(text("""
  1466. SELECT status,(SELECT count(*) FROM edge_gateway_events WHERE task_id=:task_id) AS events
  1467. FROM edge_gateway_tasks WHERE task_id=:task_id
  1468. """), {"task_id": task["task_id"]}).mappings().one()
  1469. assert (row["status"], row["events"]) in {("completed", 1), ("cancel_requested", 0)}
  1470. assert bool(accepted) != bool(cancelled)
  1471. def test_cancel_event_barriers_prove_both_lock_orders(service, engine):
  1472. gateway = _register(service, _enrollment(service))
  1473. auth = {"credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1474. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1475. "network_zone": "manufacturing-zone-a", "generation": 1}
  1476. def issue_and_pull(task_id):
  1477. task = service.issue_task({
  1478. "task_id": task_id, "gateway_id": gateway["gateway_id"], "environment": "staging",
  1479. "network_zone": "manufacturing-zone-a", "purpose": "inventory", "classification": "statistics",
  1480. "task_type": "profile", "contract_version": 1,
  1481. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1482. "attempt": 1, "idempotency_key": f"{task_id}-key", "policy_digest": "a" * 64,
  1483. }, actor_uid=_uid())
  1484. return task, service.pull_task(**auth)
  1485. cancel_task, cancel_lease = issue_and_pull("task-cancel-wins")
  1486. cancel_has_gateway = Event()
  1487. cancel_barrier = Barrier(2)
  1488. class CancelFirstRepository(EdgeGatewayRepository):
  1489. def cancel_task(self, task_id, actor_uid):
  1490. self.session.execute(text("""
  1491. SELECT g.uid FROM edge_gateway_tasks t JOIN edge_gateways g ON g.uid=t.gateway_id
  1492. WHERE t.task_id=:task_id FOR UPDATE OF g
  1493. """), {"task_id": task_id}).scalar_one()
  1494. cancel_has_gateway.set()
  1495. cancel_barrier.wait(timeout=5)
  1496. return super().cancel_task(task_id, actor_uid)
  1497. def cancel_first():
  1498. session = Session(engine)
  1499. try:
  1500. return _service(CancelFirstRepository(session)).cancel_task(
  1501. cancel_task["task_id"], actor_uid=_uid(),
  1502. )
  1503. finally:
  1504. session.close()
  1505. def event_after_cancel_lock():
  1506. assert cancel_has_gateway.wait(timeout=5)
  1507. cancel_barrier.wait(timeout=5)
  1508. session = Session(engine)
  1509. try:
  1510. return _service(EdgeGatewayRepository(session)).accept_event(
  1511. event=_event(cancel_task, gateway), lease_token=cancel_lease["lease_token"], **auth,
  1512. )
  1513. except EdgeGatewayError:
  1514. return None
  1515. finally:
  1516. session.close()
  1517. with ThreadPoolExecutor(max_workers=2) as pool:
  1518. cancel_result = pool.submit(cancel_first)
  1519. event_result = pool.submit(event_after_cancel_lock)
  1520. assert cancel_result.result() is True
  1521. assert event_result.result() is None
  1522. event_task, event_lease = issue_and_pull("task-event-wins")
  1523. event_has_gateway = Event()
  1524. event_barrier = Barrier(2)
  1525. class EventFirstRepository(EdgeGatewayRepository):
  1526. def authenticate(self, credential_hash, *, lock=False):
  1527. identity = super().authenticate(credential_hash, lock=lock)
  1528. if lock:
  1529. event_has_gateway.set()
  1530. event_barrier.wait(timeout=5)
  1531. return identity
  1532. def event_first():
  1533. session = Session(engine)
  1534. try:
  1535. return _service(EventFirstRepository(session)).accept_event(
  1536. event=_event(event_task, gateway), lease_token=event_lease["lease_token"], **auth,
  1537. )
  1538. finally:
  1539. session.close()
  1540. def cancel_after_event_lock():
  1541. assert event_has_gateway.wait(timeout=5)
  1542. event_barrier.wait(timeout=5)
  1543. session = Session(engine)
  1544. try:
  1545. return _service(EdgeGatewayRepository(session)).cancel_task(
  1546. event_task["task_id"], actor_uid=_uid(),
  1547. )
  1548. finally:
  1549. session.close()
  1550. with ThreadPoolExecutor(max_workers=2) as pool:
  1551. event_result = pool.submit(event_first)
  1552. cancel_result = pool.submit(cancel_after_event_lock)
  1553. assert event_result.result()["status"] == "accepted"
  1554. assert cancel_result.result() is False
  1555. with engine.connect() as connection:
  1556. rows = dict(connection.execute(text("""
  1557. SELECT task_id,status FROM edge_gateway_tasks
  1558. WHERE task_id IN ('task-cancel-wins','task-event-wins')
  1559. """)).all())
  1560. assert rows == {"task-cancel-wins": "cancel_requested", "task-event-wins": "completed"}
  1561. def test_machine_operations_and_rotate_are_gateway_fenced_with_barrier(service, engine):
  1562. gateway = _register(service, _enrollment(service))
  1563. auth = {"credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1564. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1565. "network_zone": "manufacturing-zone-a", "generation": 1}
  1566. release = service.offer_release(
  1567. gateway["gateway_id"], version="4.0.0", artifact_digest="7" * 64,
  1568. rollback_version="3.0.0", request_id="barrier-release", actor_uid=_uid(),
  1569. )
  1570. barrier = Barrier(3)
  1571. def run_machine(kind):
  1572. session = Session(engine)
  1573. worker = _service(EdgeGatewayRepository(session))
  1574. try:
  1575. barrier.wait()
  1576. if kind == "reconcile":
  1577. return worker.reconcile(**auth)
  1578. return worker.acknowledge_release(
  1579. release_id=release["release_id"], outcome="accepted",
  1580. safe_summary={"reason_code": "accepted"}, **auth,
  1581. )
  1582. except EdgeGatewayError:
  1583. return None
  1584. finally:
  1585. session.close()
  1586. def run_rotate():
  1587. session = Session(engine)
  1588. try:
  1589. barrier.wait()
  1590. return _service(EdgeGatewayRepository(session)).rotate(
  1591. gateway["gateway_id"], certificate_sha256="c" * 64,
  1592. request_id="barrier-rotate", actor_uid=_uid(),
  1593. )
  1594. finally:
  1595. session.close()
  1596. with ThreadPoolExecutor(max_workers=3) as pool:
  1597. results = [future.result() for future in (
  1598. pool.submit(run_machine, "reconcile"), pool.submit(run_machine, "ack"), pool.submit(run_rotate),
  1599. )]
  1600. assert results[2]["generation"] == 2
  1601. with pytest.raises(EdgeGatewayAuthenticationError):
  1602. service.reconcile(**auth)
  1603. def test_pull_event_and_revoke_are_gateway_fenced_with_barrier(service, engine):
  1604. gateway = _register(service, _enrollment(service))
  1605. auth = {"credential": gateway["credential"], "certificate_sha256": "b" * 64,
  1606. "gateway_id": gateway["gateway_id"], "environment": "staging",
  1607. "network_zone": "manufacturing-zone-a", "generation": 1}
  1608. def issue(task_id):
  1609. return service.issue_task({
  1610. "task_id": task_id, "gateway_id": gateway["gateway_id"], "environment": "staging",
  1611. "network_zone": "manufacturing-zone-a", "purpose": "inventory", "classification": "statistics",
  1612. "task_type": "profile", "contract_version": 1,
  1613. "deadline_at": (datetime.now(UTC) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z"),
  1614. "attempt": 1, "idempotency_key": f"{task_id}-key", "policy_digest": "a" * 64,
  1615. }, actor_uid=_uid())
  1616. event_task = issue("task-revoke-event")
  1617. event_lease = service.pull_task(**auth)
  1618. issue("task-revoke-pull")
  1619. event = _event(event_task, gateway)
  1620. barrier = Barrier(3)
  1621. def machine(kind):
  1622. session = Session(engine)
  1623. worker = _service(EdgeGatewayRepository(session))
  1624. try:
  1625. barrier.wait()
  1626. if kind == "event":
  1627. return worker.accept_event(event=event, lease_token=event_lease["lease_token"], **auth)
  1628. return worker.pull_task(**auth)
  1629. except EdgeGatewayError:
  1630. return None
  1631. finally:
  1632. session.close()
  1633. def revoke():
  1634. session = Session(engine)
  1635. try:
  1636. barrier.wait()
  1637. return _service(EdgeGatewayRepository(session)).revoke(gateway["gateway_id"], actor_uid=_uid())
  1638. finally:
  1639. session.close()
  1640. with ThreadPoolExecutor(max_workers=3) as pool:
  1641. results = [future.result() for future in (
  1642. pool.submit(machine, "event"), pool.submit(machine, "pull"), pool.submit(revoke),
  1643. )]
  1644. assert results[2] is True
  1645. with pytest.raises(EdgeGatewayAuthenticationError):
  1646. service.pull_task(**auth)
  1647. def test_failure_window_uses_lock_time_not_stale_transaction_time(engine):
  1648. fingerprint = hashlib.sha256(f"stale-clock:{_uid()}".encode()).hexdigest()
  1649. statement = text("""
  1650. SELECT occurrence_count
  1651. FROM public.record_edge_gateway_failure(
  1652. CAST(:audit_uid AS uuid), :fingerprint, NULL,
  1653. 'untrusted_request_rejected', 'untrusted_failure'
  1654. )
  1655. """)
  1656. older = engine.connect()
  1657. transaction = older.begin()
  1658. try:
  1659. older.execute(text("SELECT CURRENT_TIMESTAMP,pg_sleep(0.02)"))
  1660. with engine.begin() as newer:
  1661. first = newer.execute(statement, {
  1662. "audit_uid": _uid(), "fingerprint": fingerprint,
  1663. }).scalar_one()
  1664. second = older.execute(statement, {
  1665. "audit_uid": _uid(), "fingerprint": fingerprint,
  1666. }).scalar_one()
  1667. transaction.commit()
  1668. finally:
  1669. if transaction.is_active:
  1670. transaction.rollback()
  1671. older.close()
  1672. assert (first, second) == (1, 2)
  1673. def test_edge_flask_postgres_machine_binding_and_human_permissions(monkeypatch, engine):
  1674. monkeypatch.setenv("DATABASE_URL", DATABASE_URL)
  1675. actor = _uid()
  1676. monkeypatch.setattr(
  1677. "app.core.system.permissions.authenticate_request",
  1678. lambda: {"id": actor, "sub": actor, "roles": ["admin"]},
  1679. )
  1680. from app import create_app
  1681. app = create_app()
  1682. app.config.update(
  1683. TESTING=True,
  1684. EDGE_GATEWAY_SIGNING_PRIVATE_KEY=_SIGNING_PRIVATE_KEY,
  1685. EDGE_GATEWAY_SIGNING_KEY_ID=_SIGNING_KEY_ID,
  1686. EDGE_MTLS_TRUSTED_PROXY_IPS=("127.0.0.1",),
  1687. )
  1688. certificate_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  1689. certificate_name = x509.Name(
  1690. [x509.NameAttribute(NameOID.COMMON_NAME, "plant-route-edge")]
  1691. )
  1692. now = datetime.now(UTC)
  1693. certificate = (
  1694. x509.CertificateBuilder()
  1695. .subject_name(certificate_name)
  1696. .issuer_name(certificate_name)
  1697. .public_key(certificate_key.public_key())
  1698. .serial_number(x509.random_serial_number())
  1699. .not_valid_before(now - timedelta(minutes=1))
  1700. .not_valid_after(now + timedelta(minutes=10))
  1701. .sign(certificate_key, hashes.SHA256())
  1702. )
  1703. certificate_pem = certificate.public_bytes(serialization.Encoding.PEM).decode()
  1704. certificate_sha256 = certificate.fingerprint(hashes.SHA256()).hex()
  1705. def mtls_headers(**values):
  1706. return {
  1707. "X-DataOps-Edge-Client-Cert": quote(certificate_pem, safe=""),
  1708. "X-DataOps-Edge-Client-Verify": "SUCCESS",
  1709. "X-Edge-Certificate-SHA256": certificate_sha256,
  1710. **values,
  1711. }
  1712. client = app.test_client()
  1713. oversized = client.post("/api/datasource/edge/enrollments", json={"padding": "x" * 270_000})
  1714. assert oversized.status_code == 413
  1715. enrollment_response = client.post("/api/datasource/edge/enrollments", json={
  1716. "gateway_name": "plant-route", "environment": "staging",
  1717. "network_zone": "zone-route", "policy_digest": "e" * 64,
  1718. "allowed_control_hosts": ["control.example.test"], "allowed_proxy_hosts": [],
  1719. "expected_certificate_sha256": certificate_sha256,
  1720. "ttl_seconds": 600,
  1721. })
  1722. assert enrollment_response.status_code == 201
  1723. assert enrollment_response.headers["Cache-Control"] == "no-store"
  1724. enrollment = enrollment_response.get_json()["data"]
  1725. missing_tls_proof = client.post("/api/datasource/edge/register", headers={
  1726. "X-Edge-Enrollment": enrollment["enrollment_token"],
  1727. "X-Edge-Certificate-SHA256": certificate_sha256,
  1728. }, json={
  1729. "gateway_id": enrollment["gateway_id"], "environment": "staging",
  1730. "network_zone": "zone-route", "policy_digest": "e" * 64,
  1731. "allowed_control_hosts": ["control.example.test"], "allowed_proxy_hosts": [],
  1732. "version": "3.0.0",
  1733. })
  1734. assert missing_tls_proof.status_code == 401
  1735. spoofed_tls_proof = client.post(
  1736. "/api/datasource/edge/register",
  1737. headers=mtls_headers(**{
  1738. "X-Edge-Enrollment": enrollment["enrollment_token"],
  1739. }),
  1740. environ_overrides={"REMOTE_ADDR": "203.0.113.7"},
  1741. json={
  1742. "gateway_id": enrollment["gateway_id"], "environment": "staging",
  1743. "network_zone": "zone-route", "policy_digest": "e" * 64,
  1744. "allowed_control_hosts": ["control.example.test"], "allowed_proxy_hosts": [],
  1745. "version": "3.0.0",
  1746. },
  1747. )
  1748. assert spoofed_tls_proof.status_code == 401
  1749. register = client.post("/api/datasource/edge/register", headers=mtls_headers(**{
  1750. "X-Edge-Enrollment": enrollment["enrollment_token"],
  1751. }), environ_overrides={"REMOTE_ADDR": "127.0.0.1"}, json={
  1752. "gateway_id": enrollment["gateway_id"], "environment": "staging",
  1753. "network_zone": "zone-route", "policy_digest": "e" * 64,
  1754. "allowed_control_hosts": ["control.example.test"], "allowed_proxy_hosts": [],
  1755. "version": "3.0.0",
  1756. })
  1757. assert register.status_code == 201
  1758. assert register.headers["Cache-Control"] == "no-store"
  1759. registered = register.get_json()["data"]
  1760. bad = client.post(
  1761. f"/api/datasource/edge/gateways/{registered['gateway_id']}/heartbeat",
  1762. headers=mtls_headers(**{"X-Edge-Credential": registered["credential"] + "wrong"}),
  1763. environ_overrides={"REMOTE_ADDR": "127.0.0.1"},
  1764. json={"environment": "staging", "network_zone": "zone-route", "generation": 1, "version": "3.0.0", "safe_summary": {}},
  1765. )
  1766. assert bad.status_code == 401
  1767. assert bad.headers["Cache-Control"] == "no-store"
  1768. assert registered["credential"] not in bad.get_data(as_text=True)
  1769. limited = None
  1770. for _ in range(9):
  1771. limited = client.post(
  1772. f"/api/datasource/edge/gateways/{registered['gateway_id']}/heartbeat",
  1773. headers=mtls_headers(**{"X-Edge-Credential": registered["credential"] + "wrong"}),
  1774. environ_overrides={"REMOTE_ADDR": "127.0.0.1"},
  1775. json={"environment": "staging", "network_zone": "zone-route", "generation": 1, "version": "3.0.0", "safe_summary": {}},
  1776. )
  1777. assert limited is not None and limited.status_code == 429
  1778. assert limited.headers["Cache-Control"] == "no-store"
  1779. with engine.connect() as connection:
  1780. window = connection.execute(text("SELECT occurrence_count FROM edge_gateway_failure_windows")).scalar_one()
  1781. failure_rows = connection.execute(text("SELECT count(*) FROM edge_gateway_audits WHERE success=false")).scalar_one()
  1782. assert window == 10
  1783. assert failure_rows == 1
  1784. listed = client.get("/api/datasource/edge/gateways")
  1785. assert listed.status_code == 200
  1786. assert listed.get_json()["data"]["gateways"][0]["gateway_id"] == registered["gateway_id"]
  1787. assert client.get("/api/datasource/edge/gateways?limit=101").status_code == 400
  1788. oversized_reconcile = client.post(
  1789. f"/api/datasource/edge/gateways/{registered['gateway_id']}/reconcile",
  1790. headers=mtls_headers(**{"X-Edge-Credential": registered["credential"]}),
  1791. environ_overrides={"REMOTE_ADDR": "127.0.0.1"},
  1792. json={"environment": "staging", "network_zone": "zone-route", "generation": 1, "limit": 101},
  1793. )
  1794. assert oversized_reconcile.status_code == 400
  1795. app.config["EDGE_GATEWAY_SIGNING_PRIVATE_KEY"] = ""
  1796. unavailable_signer = client.post(
  1797. f"/api/datasource/edge/gateways/{registered['gateway_id']}/releases",
  1798. json={
  1799. "version": "7.0.0", "artifact_digest": "7" * 64,
  1800. "rollback_version": "6.0.0", "request_id": "no-production-signer",
  1801. },
  1802. )
  1803. assert unavailable_signer.status_code == 503
  1804. assert unavailable_signer.get_json()["error"]["code"] == "EDGE_GATEWAY_SIGNING_UNAVAILABLE"