test_phase3_wp07_enterprise_delivery_contract.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962
  1. """Security and delivery-chain contracts for P3-WP07."""
  2. from __future__ import annotations
  3. import hashlib
  4. import importlib.util
  5. import json
  6. import os
  7. import re
  8. import subprocess
  9. import sys
  10. from datetime import UTC, datetime, timedelta
  11. from pathlib import Path
  12. import jsonschema
  13. import pytest
  14. from cryptography.hazmat.primitives import serialization
  15. from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
  16. FAKE_PRIVATE_KEY = bytes.fromhex(
  17. "4f3b2775a4df921336c3882f071d9edce4a6e946b928345f7bf68b55d4eaa3d8"
  18. )
  19. SECOND_PRIVATE_KEY = bytes.fromhex("1f" * 32)
  20. ROOT = Path(__file__).resolve().parents[1]
  21. CLI = ROOT / "deployment/enterprise/enterprise_delivery.py"
  22. CHART = ROOT / "deployment/helm/dataops-platform"
  23. VALUES = CHART / "values.enterprise.example.json"
  24. SCHEMA = CHART / "values.schema.json"
  25. SPEC = importlib.util.spec_from_file_location("wp07_delivery", CLI)
  26. assert SPEC and SPEC.loader
  27. DELIVERY = importlib.util.module_from_spec(SPEC)
  28. SPEC.loader.exec_module(DELIVERY)
  29. def _sha(path: Path) -> str:
  30. return hashlib.sha256(path.read_bytes()).hexdigest()
  31. def _run(*args: str, expect: int = 0) -> dict[str, object]:
  32. completed = subprocess.run(
  33. [sys.executable, str(CLI), *args], cwd=ROOT, capture_output=True, text=True
  34. )
  35. assert completed.returncode == expect, completed.stderr
  36. return json.loads(completed.stdout)
  37. def _evidence(root: Path, artifact: bytes = b"candidate") -> dict[str, Path]:
  38. root.mkdir()
  39. artifact_path = root / "artifact.tar.gz"
  40. artifact_path.write_bytes(artifact)
  41. sbom = root / "sbom.json"
  42. sbom_value = {
  43. "bomFormat": "CycloneDX",
  44. "specVersion": "1.5",
  45. "serialNumber": "urn:uuid:11111111-1111-4111-8111-111111111111",
  46. "version": 1,
  47. "metadata": {
  48. "properties": [
  49. {"name": "dataops:artifact-sha256", "value": _sha(artifact_path)}
  50. ]
  51. },
  52. "components": [],
  53. }
  54. sbom.write_text(json.dumps(sbom_value))
  55. now = datetime.now(UTC)
  56. scan = root / "scan.json"
  57. license_file = root / "license.json"
  58. private = Ed25519PrivateKey.from_private_bytes(FAKE_PRIVATE_KEY)
  59. payload = {"artifact_sha256": _sha(artifact_path), "sbom_sha256": _sha(sbom)}
  60. signed = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
  61. signature = root / "signature.json"
  62. signature.write_text(
  63. json.dumps(
  64. {
  65. "algorithm": "Ed25519",
  66. "artifact_sha256": payload["artifact_sha256"],
  67. "sbom_sha256": payload["sbom_sha256"],
  68. "provider": "fake_local_only",
  69. "key_id": "wp07-local-test",
  70. "issued_at": (now - timedelta(minutes=1)).isoformat(),
  71. "expires_at": (now + timedelta(hours=1)).isoformat(),
  72. "signature": private.sign(signed).hex(),
  73. }
  74. )
  75. )
  76. signature_sha = _sha(signature)
  77. scan.write_text(
  78. json.dumps(
  79. {
  80. "artifact_sha256": _sha(artifact_path),
  81. "sbom_sha256": _sha(sbom),
  82. "status": "clear",
  83. "provider": "fake_local_only",
  84. "key_id": "wp07-local-test",
  85. "issued_at": (now - timedelta(minutes=1)).isoformat(),
  86. "expires_at": (now + timedelta(hours=1)).isoformat(),
  87. "signature_sha256": signature_sha,
  88. }
  89. )
  90. )
  91. license_file.write_text(
  92. json.dumps(
  93. {
  94. "artifact_sha256": _sha(artifact_path),
  95. "sbom_sha256": _sha(sbom),
  96. "status": "clear",
  97. "provider": "fake_local_only",
  98. "key_id": "wp07-local-test",
  99. "issued_at": (now - timedelta(minutes=1)).isoformat(),
  100. "expires_at": (now + timedelta(hours=1)).isoformat(),
  101. "signature_sha256": signature_sha,
  102. }
  103. )
  104. )
  105. return {
  106. "artifact": artifact_path,
  107. "sbom": sbom,
  108. "scan": scan,
  109. "license": license_file,
  110. "signature": signature,
  111. }
  112. def _contract_manifest(
  113. path: Path, artifact: Path, sbom: Path, *, openapi: Path | None = None
  114. ) -> dict[str, object]:
  115. component_identity = hashlib.sha256(b"[]").hexdigest()
  116. return {
  117. "artifact_sha256": _sha(artifact),
  118. "sbom_sha256": _sha(sbom),
  119. "component_identity_sha256": component_identity,
  120. "openapi": str(openapi or ROOT / "docs/architecture/OPENAPI.yaml"),
  121. "config": str(SCHEMA),
  122. "chart": str(CHART / "Chart.yaml"),
  123. "values": str(VALUES),
  124. "migration_head": "20260811_495",
  125. "migration_chain": ["20260811_495"],
  126. }
  127. def _release_record(artifact: Path, sbom: Path) -> dict[str, str]:
  128. sbom_value = json.loads(sbom.read_text())
  129. identity = [
  130. {
  131. "bom-ref": component.get("bom-ref", ""),
  132. "name": component.get("name", ""),
  133. "version": component.get("version", ""),
  134. "purl": component.get("purl", ""),
  135. }
  136. for component in sbom_value["components"]
  137. ]
  138. identity.sort(key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")))
  139. return {
  140. "artifact_sha256": _sha(artifact),
  141. "sbom_sha256": _sha(sbom),
  142. "sbom_serial": sbom_value["serialNumber"],
  143. "component_identity_sha256": hashlib.sha256(
  144. json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()
  145. ).hexdigest(),
  146. }
  147. def _contract_sha(manifest: dict[str, object]) -> str:
  148. openapi = Path(str(manifest["openapi"]))
  149. config = Path(str(manifest["config"]))
  150. chart = Path(str(manifest["chart"]))
  151. values = Path(str(manifest["values"]))
  152. value = {
  153. "openapi_sha256": _sha(openapi),
  154. "config_schema_sha256": _sha(config),
  155. "chart_sha256": _sha(chart),
  156. "values_sha256": _sha(values),
  157. "migration_head": manifest["migration_head"],
  158. "migration_chain": manifest["migration_chain"],
  159. "openapi_operations": sorted(
  160. re.findall(r"^ (/[^:\s]+):", openapi.read_text(), re.MULTILINE)
  161. ),
  162. "config_required": sorted(json.loads(config.read_text()).get("required", [])),
  163. }
  164. return hashlib.sha256(
  165. json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
  166. ).hexdigest()
  167. def _registry_signer(private_key: bytes, trust_root: str) -> dict[str, str]:
  168. public = Ed25519PrivateKey.from_private_bytes(private_key).public_key().public_bytes(
  169. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  170. )
  171. return {
  172. "algorithm": "Ed25519",
  173. "trust_root": trust_root,
  174. "public_key": public.hex(),
  175. "public_key_sha256": hashlib.sha256(public).hexdigest(),
  176. }
  177. def _temporary_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
  178. value = json.loads((ROOT / "deployment/enterprise/trust_store.json").read_text())
  179. value["providers"]["external"] = {
  180. "enabled": True,
  181. "approved_signers": {
  182. "key-a": _registry_signer(FAKE_PRIVATE_KEY, "test-external-root-a"),
  183. "key-b": _registry_signer(SECOND_PRIVATE_KEY, "test-external-root-b"),
  184. },
  185. }
  186. value["providers"]["fake_local_only"]["approved_signers"]["key-b"] = _registry_signer(
  187. SECOND_PRIVATE_KEY, "test-local-root-b"
  188. )
  189. path = tmp_path / "trust-store.json"
  190. path.write_text(json.dumps(value))
  191. monkeypatch.setattr(DELIVERY, "TRUST_STORE", path)
  192. return path
  193. def _candidate(
  194. state: Path, evidence: dict[str, Path], environment: str
  195. ) -> dict[str, object]:
  196. return _run(
  197. "candidate",
  198. "--state-dir",
  199. str(state),
  200. "--environment",
  201. environment,
  202. "--version",
  203. "20260812.2",
  204. "--artifact",
  205. str(evidence["artifact"]),
  206. "--sbom",
  207. str(evidence["sbom"]),
  208. "--scan",
  209. str(evidence["scan"]),
  210. "--license",
  211. str(evidence["license"]),
  212. "--signature",
  213. str(evidence["signature"]),
  214. "--approval-ref",
  215. "CHG-1201",
  216. "--test-only",
  217. )
  218. def test_chart_has_real_service_hooks_and_strict_network_policy():
  219. schema = json.loads(SCHEMA.read_text())
  220. jsonschema.validate(json.loads(VALUES.read_text()), schema)
  221. service = (CHART / "templates/service.yaml").read_text()
  222. controls = (CHART / "templates/controls.yaml").read_text()
  223. workloads = (CHART / "templates/workloads.yaml").read_text()
  224. assert (
  225. "kind: Service" in service
  226. and "selector:" in service
  227. and "targetPort:" in service
  228. )
  229. assert "helm.sh/hook: pre-install,pre-upgrade" in controls
  230. assert "dataops-hook-bootstrap" in controls
  231. assert 'helm.sh/hook-weight: "-40"' in controls
  232. assert 'helm.sh/hook-weight: "-39"' in controls
  233. assert 'helm.sh/hook-weight: "-30"' in controls
  234. assert 'helm.sh/hook-weight: "-20"' in controls
  235. assert 'helm.sh/hook-weight: "-10"' in controls
  236. assert "dataops-role-init" in controls and "dataops-db-migrate" in controls
  237. assert "kube-dns" in controls and "dataops-postgres" in controls
  238. assert "dataops-neo4j" in controls and "dataops-minio" in controls
  239. assert "dataops-hook-egress" in controls and "dataops-backend-ingress" in controls
  240. assert "dataops-ingress-controller" in controls
  241. assert "namespaceSelector:" in controls and "ports:" in controls
  242. assert "egress: [{}]" not in controls and "- {}" not in controls
  243. assert "depends_on" not in workloads
  244. assert "before-hook-creation" in controls
  245. runtime_service_account = workloads.split("---", 1)[0]
  246. assert "namespace: {{ .Values.namespace }}" in runtime_service_account
  247. def test_candidate_recomputes_evidence_and_rejects_tamper_expiry_and_symlink(
  248. tmp_path: Path,
  249. ):
  250. state = tmp_path / "state"
  251. evidence = _evidence(tmp_path / "evidence")
  252. candidate = _candidate(state, evidence, "development")
  253. assert candidate["status"] == "candidate_created"
  254. persisted = state / "candidates" / f"{candidate['candidate_digest']}.json"
  255. os.link(persisted, state / "linked-state.json")
  256. assert (
  257. _run(
  258. "candidate",
  259. "--state-dir",
  260. str(state),
  261. "--environment",
  262. "development",
  263. "--version",
  264. "20260812.2",
  265. "--artifact",
  266. str(evidence["artifact"]),
  267. "--sbom",
  268. str(evidence["sbom"]),
  269. "--scan",
  270. str(evidence["scan"]),
  271. "--license",
  272. str(evidence["license"]),
  273. "--signature",
  274. str(evidence["signature"]),
  275. "--approval-ref",
  276. "CHG-1201",
  277. expect=2,
  278. )["status"]
  279. == "rejected"
  280. )
  281. evidence["artifact"].write_bytes(b"tampered")
  282. rejected = _run(
  283. "candidate",
  284. "--state-dir",
  285. str(state),
  286. "--environment",
  287. "development",
  288. "--version",
  289. "20260812.2",
  290. "--artifact",
  291. str(evidence["artifact"]),
  292. "--sbom",
  293. str(evidence["sbom"]),
  294. "--scan",
  295. str(evidence["scan"]),
  296. "--license",
  297. str(evidence["license"]),
  298. "--signature",
  299. str(evidence["signature"]),
  300. "--approval-ref",
  301. "CHG-1201",
  302. expect=2,
  303. )
  304. assert rejected["status"] == "rejected"
  305. expired = _evidence(tmp_path / "expired")
  306. scan_value = json.loads(expired["scan"].read_text())
  307. scan_value["expires_at"] = "2000-01-01T00:00:00+00:00"
  308. expired["scan"].write_text(json.dumps(scan_value))
  309. assert _run(
  310. "candidate",
  311. "--state-dir", str(state), "--environment", "test", "--version", "expired",
  312. "--artifact", str(expired["artifact"]), "--sbom", str(expired["sbom"]),
  313. "--scan", str(expired["scan"]), "--license", str(expired["license"]),
  314. "--signature", str(expired["signature"]), "--approval-ref", "CHG-expired",
  315. expect=2,
  316. )["status"] == "rejected"
  317. assert any(term in rejected["error"] for term in ("scan", "signature", "SBOM"))
  318. state_link = tmp_path / "state-link"
  319. state_link.symlink_to(state, target_is_directory=True)
  320. rejected = _run(
  321. "candidate",
  322. "--state-dir",
  323. str(state_link),
  324. "--environment",
  325. "development",
  326. "--version",
  327. "bad",
  328. "--artifact",
  329. str(evidence["artifact"]),
  330. "--sbom",
  331. str(evidence["sbom"]),
  332. "--scan",
  333. str(evidence["scan"]),
  334. "--license",
  335. str(evidence["license"]),
  336. "--signature",
  337. str(evidence["signature"]),
  338. "--approval-ref",
  339. "CHG-1202",
  340. expect=2,
  341. )
  342. assert rejected["status"] == "rejected"
  343. unsafe = tmp_path / "unsafe-state"
  344. unsafe.mkdir(mode=0o700)
  345. outside = tmp_path / "outside"
  346. outside.mkdir(mode=0o700)
  347. (unsafe / "candidates").symlink_to(outside, target_is_directory=True)
  348. clean = _evidence(tmp_path / "clean")
  349. assert (
  350. _run(
  351. "candidate", "--state-dir", str(unsafe), "--environment", "development",
  352. "--version", "unsafe", "--artifact", str(clean["artifact"]),
  353. "--sbom", str(clean["sbom"]), "--scan", str(clean["scan"]),
  354. "--license", str(clean["license"]), "--signature", str(clean["signature"]),
  355. "--approval-ref", "CHG-unsafe", expect=2,
  356. )["status"]
  357. == "rejected"
  358. )
  359. assert not list(outside.iterdir())
  360. promote_state = tmp_path / "promotion-state"
  361. promote_evidence = _evidence(tmp_path / "promotion-evidence")
  362. promote_candidate = _candidate(promote_state, promote_evidence, "development")
  363. promote_outside = tmp_path / "promotion-outside"
  364. promote_outside.mkdir(mode=0o700)
  365. (promote_state / "current").symlink_to(promote_outside, target_is_directory=True)
  366. assert _run(
  367. "promote", "--state-dir", str(promote_state), "--environment", "development",
  368. "--candidate-digest", promote_candidate["candidate_digest"],
  369. "--expected-base-digest", "none", expect=2,
  370. )["status"] == "rejected"
  371. assert not list(promote_outside.iterdir())
  372. def test_candidate_rejects_self_signed_external_provider(tmp_path: Path):
  373. state = tmp_path / "state"
  374. evidence = _evidence(tmp_path / "external")
  375. signature = json.loads(evidence["signature"].read_text())
  376. signature["provider"] = "external"
  377. signature["key_id"] = "caller-supplied-root"
  378. evidence["signature"].write_text(json.dumps(signature))
  379. signature_sha = _sha(evidence["signature"])
  380. for evidence_name in ("scan", "license"):
  381. value = json.loads(evidence[evidence_name].read_text())
  382. value["provider"] = "external"
  383. value["key_id"] = "caller-supplied-root"
  384. value["signature_sha256"] = signature_sha
  385. evidence[evidence_name].write_text(json.dumps(value))
  386. assert _run(
  387. "candidate", "--state-dir", str(state), "--environment", "production",
  388. "--version", "external", "--artifact", str(evidence["artifact"]),
  389. "--sbom", str(evidence["sbom"]), "--scan", str(evidence["scan"]),
  390. "--license", str(evidence["license"]), "--signature", str(evidence["signature"]),
  391. "--approval-ref", "CHG-external", expect=2,
  392. )["status"] == "rejected"
  393. def test_candidate_uses_configured_key_and_binds_scan_to_same_sbom(tmp_path: Path):
  394. """The evidence payload cannot introduce its own trust root or SBOM binding."""
  395. state = tmp_path / "state"
  396. evidence = _evidence(tmp_path / "registry-key")
  397. signature = json.loads(evidence["signature"].read_text())
  398. assert "public_key" not in signature
  399. signature_sha = _sha(evidence["signature"])
  400. for evidence_name in ("scan", "license"):
  401. value = json.loads(evidence[evidence_name].read_text())
  402. value["signature_sha256"] = signature_sha
  403. value["sbom_sha256"] = _sha(evidence["sbom"])
  404. evidence[evidence_name].write_text(json.dumps(value))
  405. assert _candidate(state, evidence, "development")["status"] == "candidate_created"
  406. payload_root = _evidence(tmp_path / "payload-root")
  407. signature = json.loads(payload_root["signature"].read_text())
  408. signature["public_key"] = "00" * 32
  409. payload_root["signature"].write_text(json.dumps(signature))
  410. signature_sha = _sha(payload_root["signature"])
  411. for evidence_name in ("scan", "license"):
  412. value = json.loads(payload_root[evidence_name].read_text())
  413. value["signature_sha256"] = signature_sha
  414. payload_root[evidence_name].write_text(json.dumps(value))
  415. assert _run(
  416. "candidate", "--state-dir", str(tmp_path / "payload-root-state"),
  417. "--environment", "test", "--version", "payload-root",
  418. "--artifact", str(payload_root["artifact"]), "--sbom", str(payload_root["sbom"]),
  419. "--scan", str(payload_root["scan"]), "--license", str(payload_root["license"]),
  420. "--signature", str(payload_root["signature"]), "--approval-ref", "CHG-root",
  421. "--test-only", expect=2,
  422. )["status"] == "rejected"
  423. mismatched = _evidence(tmp_path / "mismatched-sbom")
  424. scan = json.loads(mismatched["scan"].read_text())
  425. scan["sbom_sha256"] = "0" * 64
  426. mismatched["scan"].write_text(json.dumps(scan))
  427. assert _run(
  428. "candidate", "--state-dir", str(tmp_path / "mismatch-state"),
  429. "--environment", "test", "--version", "mismatch",
  430. "--artifact", str(mismatched["artifact"]), "--sbom", str(mismatched["sbom"]),
  431. "--scan", str(mismatched["scan"]), "--license", str(mismatched["license"]),
  432. "--signature", str(mismatched["signature"]), "--approval-ref", "CHG-sbom",
  433. "--test-only", expect=2,
  434. )["status"] == "rejected"
  435. def test_evidence_reader_rejects_symbolic_parent_directory(tmp_path: Path):
  436. actual = tmp_path / "actual"
  437. actual.mkdir()
  438. evidence = actual / "evidence.json"
  439. evidence.write_text('{"safe": true}')
  440. linked = tmp_path / "linked"
  441. linked.symlink_to(actual, target_is_directory=True)
  442. with pytest.raises(DELIVERY.DeliveryError, match="symbolic"):
  443. DELIVERY.safe_bytes(linked / "evidence.json")
  444. def test_compatibility_requires_three_bound_contract_manifests(tmp_path: Path):
  445. artifact = tmp_path / "artifact.tar.gz"
  446. artifact.write_bytes(b"valid artifact")
  447. evidence = _evidence(tmp_path / "evidence", b"valid artifact")
  448. assert _run(
  449. "compatibility", "--output", str(tmp_path / "matrix.json"),
  450. "--base-version", "base", "--candidate-version", "candidate",
  451. "--base-artifact", str(artifact), "--base-sbom", str(evidence["sbom"]),
  452. "--candidate-artifact", str(artifact), "--candidate-sbom", str(evidence["sbom"]),
  453. "--rollback-artifact", str(artifact), "--rollback-sbom", str(evidence["sbom"]),
  454. expect=2,
  455. )["status"] == "rejected"
  456. def test_compatibility_rejects_caller_authored_unsigned_approval(tmp_path: Path):
  457. base_artifact = tmp_path / "base.tar.gz"
  458. candidate_artifact = tmp_path / "candidate.tar.gz"
  459. base_artifact.write_bytes(b"base")
  460. candidate_artifact.write_bytes(b"unrelated candidate")
  461. base_evidence = _evidence(tmp_path / "base-evidence", b"base")
  462. candidate_evidence = _evidence(tmp_path / "candidate-evidence", b"unrelated candidate")
  463. manifests: dict[str, Path] = {}
  464. sources = {
  465. "base": (base_artifact, base_evidence["sbom"]),
  466. "candidate": (candidate_artifact, candidate_evidence["sbom"]),
  467. "rollback": (base_artifact, base_evidence["sbom"]),
  468. }
  469. contract_values: dict[str, dict[str, object]] = {}
  470. for name, (artifact, sbom) in sources.items():
  471. path = tmp_path / f"{name}-contract.json"
  472. value = _contract_manifest(path, artifact, sbom)
  473. path.write_text(json.dumps(value))
  474. manifests[name] = path
  475. contract_values[name] = value
  476. approval = {
  477. "base": _release_record(base_artifact, base_evidence["sbom"]),
  478. "candidate": _release_record(candidate_artifact, candidate_evidence["sbom"]),
  479. "base_contract_sha256": _contract_sha(contract_values["base"]),
  480. "candidate_contract_sha256": _contract_sha(contract_values["candidate"]),
  481. "approval_ref": "CALLER-SELF-APPROVED",
  482. "provider": "external",
  483. "key_id": "caller-created",
  484. "signature": "00" * 64,
  485. }
  486. approval_path = tmp_path / "approval.json"
  487. approval_path.write_text(json.dumps(approval))
  488. assert _run(
  489. "compatibility", "--output", str(tmp_path / "matrix.json"),
  490. "--base-version", "base", "--candidate-version", "candidate",
  491. "--base-artifact", str(base_artifact), "--base-sbom", str(base_evidence["sbom"]),
  492. "--candidate-artifact", str(candidate_artifact), "--candidate-sbom", str(candidate_evidence["sbom"]),
  493. "--rollback-artifact", str(base_artifact), "--rollback-sbom", str(base_evidence["sbom"]),
  494. "--base-contract", str(manifests["base"]),
  495. "--candidate-contract", str(manifests["candidate"]),
  496. "--rollback-contract", str(manifests["rollback"]),
  497. "--compatibility-approval", str(approval_path), expect=2,
  498. )["status"] == "rejected"
  499. def test_compatibility_rejects_expired_registry_signed_approval(
  500. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  501. ):
  502. _temporary_registry(tmp_path, monkeypatch)
  503. base_artifact, candidate_artifact = tmp_path / "base.tar.gz", tmp_path / "candidate.tar.gz"
  504. base_artifact.write_bytes(b"base")
  505. candidate_artifact.write_bytes(b"candidate")
  506. base_evidence = _evidence(tmp_path / "base-evidence", b"base")
  507. candidate_evidence = _evidence(tmp_path / "candidate-evidence", b"candidate")
  508. manifests, values = {}, {}
  509. for name, artifact, sbom in (
  510. ("base", base_artifact, base_evidence["sbom"]),
  511. ("candidate", candidate_artifact, candidate_evidence["sbom"]),
  512. ("rollback", base_artifact, base_evidence["sbom"]),
  513. ):
  514. path = tmp_path / f"{name}.json"
  515. value = _contract_manifest(path, artifact, sbom)
  516. path.write_text(json.dumps(value))
  517. manifests[name], values[name] = path, value
  518. legacy_payload = {
  519. "base": _release_record(base_artifact, base_evidence["sbom"]),
  520. "candidate": _release_record(candidate_artifact, candidate_evidence["sbom"]),
  521. "base_contract_sha256": _contract_sha(values["base"]),
  522. "candidate_contract_sha256": _contract_sha(values["candidate"]),
  523. "approval_ref": "APPROVAL-EXPIRED",
  524. }
  525. approval = {
  526. **legacy_payload,
  527. "provider": "external",
  528. "key_id": "key-a",
  529. "algorithm": "Ed25519",
  530. "issued_at": "2000-01-01T00:00:00Z",
  531. "expires_at": "2000-01-01T00:05:00Z",
  532. "signature": Ed25519PrivateKey.from_private_bytes(FAKE_PRIVATE_KEY).sign(
  533. DELIVERY.canonical(legacy_payload)
  534. ).hex(),
  535. }
  536. approval_path = tmp_path / "approval.json"
  537. approval_path.write_text(json.dumps(approval))
  538. args = DELIVERY.parser().parse_args([
  539. "compatibility", "--output", str(tmp_path / "matrix.json"),
  540. "--base-version", "base", "--candidate-version", "candidate",
  541. "--base-artifact", str(base_artifact), "--base-sbom", str(base_evidence["sbom"]),
  542. "--candidate-artifact", str(candidate_artifact), "--candidate-sbom", str(candidate_evidence["sbom"]),
  543. "--rollback-artifact", str(base_artifact), "--rollback-sbom", str(base_evidence["sbom"]),
  544. "--base-contract", str(manifests["base"]), "--candidate-contract", str(manifests["candidate"]),
  545. "--rollback-contract", str(manifests["rollback"]), "--compatibility-approval", str(approval_path),
  546. ])
  547. with pytest.raises(DELIVERY.DeliveryError, match="approval evidence expired"):
  548. DELIVERY.command_compatibility(args)
  549. now = datetime.now(UTC)
  550. approval["issued_at"] = now.isoformat(timespec="seconds").replace("+00:00", "Z")
  551. approval["expires_at"] = (now + timedelta(minutes=30)).isoformat(
  552. timespec="seconds"
  553. ).replace("+00:00", "Z")
  554. signed = {
  555. **legacy_payload,
  556. "issued_at": approval["issued_at"],
  557. "expires_at": approval["expires_at"],
  558. }
  559. approval["signature"] = Ed25519PrivateKey.from_private_bytes(FAKE_PRIVATE_KEY).sign(
  560. DELIVERY.canonical(signed)
  561. ).hex()
  562. approval_path.write_text(json.dumps(approval))
  563. assert DELIVERY.command_compatibility(args)["status"] == "compatible"
  564. def test_hook_policy_lifecycle_contract_is_documented():
  565. plan = (ROOT / "docs/phase3/P3_WP07_ENTERPRISE_DELIVERY_IMPLEMENTATION_PLAN.md").read_text()
  566. assert "hook NetworkPolicy" in plan
  567. assert "pre-install/pre-upgrade" in plan
  568. assert "before-hook-creation" in plan
  569. assert "role-init/db-migrate" in plan
  570. def test_evidence_path_replacement_between_lstat_and_open_is_rejected(
  571. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  572. ):
  573. evidence = tmp_path / "evidence.json"
  574. evidence.write_text('{"old": true}')
  575. replacement = tmp_path / "replacement.json"
  576. replacement.write_text('{"new": true}')
  577. original_open = DELIVERY.os.open
  578. def replace_before_open(path: object, *args: object, **kwargs: object) -> int:
  579. if path == evidence.name and kwargs.get("dir_fd") is not None:
  580. replacement.replace(evidence)
  581. return original_open(path, *args, **kwargs)
  582. monkeypatch.setattr(DELIVERY.os, "open", replace_before_open)
  583. with pytest.raises(DELIVERY.DeliveryError, match="changed while reading"):
  584. DELIVERY.safe_bytes(evidence)
  585. def test_promotion_only_allows_adjacent_environment_and_cas_rollback(tmp_path: Path):
  586. state, evidence = tmp_path / "state", _evidence(tmp_path / "evidence")
  587. development = _candidate(state, evidence, "development")
  588. _run(
  589. "promote",
  590. "--state-dir",
  591. str(state),
  592. "--environment",
  593. "development",
  594. "--candidate-digest",
  595. development["candidate_digest"],
  596. "--expected-base-digest",
  597. "none",
  598. )
  599. direct = _candidate(state, evidence, "production")
  600. assert (
  601. _run(
  602. "promote",
  603. "--state-dir",
  604. str(state),
  605. "--environment",
  606. "production",
  607. "--candidate-digest",
  608. direct["candidate_digest"],
  609. "--expected-base-digest",
  610. "none",
  611. expect=2,
  612. )["status"]
  613. == "rejected"
  614. )
  615. test = _candidate(state, evidence, "test")
  616. _run(
  617. "promote",
  618. "--state-dir",
  619. str(state),
  620. "--environment",
  621. "test",
  622. "--candidate-digest",
  623. test["candidate_digest"],
  624. "--expected-base-digest",
  625. development["candidate_digest"],
  626. )
  627. mismatched = _candidate(state, _evidence(tmp_path / "different", b"different"), "staging")
  628. assert (
  629. _run(
  630. "promote", "--state-dir", str(state), "--environment", "staging",
  631. "--candidate-digest", mismatched["candidate_digest"],
  632. "--expected-base-digest", test["candidate_digest"], expect=2,
  633. )["status"]
  634. == "rejected"
  635. )
  636. staging = _candidate(state, evidence, "staging")
  637. _run(
  638. "promote",
  639. "--state-dir",
  640. str(state),
  641. "--environment",
  642. "staging",
  643. "--candidate-digest",
  644. staging["candidate_digest"],
  645. "--expected-base-digest",
  646. test["candidate_digest"],
  647. )
  648. production = _candidate(state, evidence, "production")
  649. _run(
  650. "promote",
  651. "--state-dir",
  652. str(state),
  653. "--environment",
  654. "production",
  655. "--candidate-digest",
  656. production["candidate_digest"],
  657. "--expected-base-digest",
  658. staging["candidate_digest"],
  659. )
  660. rolled = _run(
  661. "rollback",
  662. "--state-dir",
  663. str(state),
  664. "--environment",
  665. "production",
  666. "--target-digest",
  667. staging["candidate_digest"],
  668. "--approval-ref",
  669. "CHG-1203",
  670. )
  671. assert rolled["status"] == "rollback_recorded"
  672. def test_backup_restore_recomputes_signed_checksums_and_requires_encryption_for_production(
  673. tmp_path: Path,
  674. ):
  675. source = tmp_path / "backup-source"
  676. source.mkdir()
  677. source.chmod(0o700)
  678. data = source / "postgres.sql"
  679. data.write_text("safe backup")
  680. manifest = _run(
  681. "backup-plan",
  682. "--backup-dir",
  683. str(source),
  684. "--release-artifact",
  685. str(data),
  686. "--config-artifact",
  687. str(data),
  688. "--key-reference",
  689. "kms://external/backup",
  690. "--encryption-provider",
  691. "fake",
  692. "--test-only",
  693. )
  694. result = _run(
  695. "restore-plan",
  696. "--backup-manifest",
  697. manifest["manifest_path"],
  698. "--target",
  699. "recovery-1201",
  700. "--confirm-target",
  701. "recovery-1201",
  702. "--environment",
  703. "test",
  704. "--test-only",
  705. )
  706. assert result["status"] == "restore_to_new_target_planned"
  707. data.write_text("tampered")
  708. assert (
  709. _run(
  710. "restore-plan",
  711. "--backup-manifest",
  712. manifest["manifest_path"],
  713. "--target",
  714. "recovery-1202",
  715. "--confirm-target",
  716. "recovery-1202",
  717. "--environment",
  718. "test",
  719. "--test-only",
  720. expect=2,
  721. )["status"]
  722. == "rejected"
  723. )
  724. assert (
  725. _run(
  726. "backup-plan",
  727. "--backup-dir",
  728. str(tmp_path / "disabled"),
  729. "--release-artifact",
  730. str(data),
  731. "--config-artifact",
  732. str(data),
  733. "--key-reference",
  734. "kms://external/backup",
  735. "--encryption-provider",
  736. "disabled",
  737. "--environment",
  738. "production",
  739. expect=2,
  740. )["status"]
  741. == "rejected"
  742. )
  743. def test_production_external_backup_fails_closed_without_approved_kms_and_signer(
  744. tmp_path: Path,
  745. ):
  746. source = tmp_path / "external-backup"
  747. source.mkdir(mode=0o700)
  748. data = source / "postgres.sql"
  749. data.write_text("backup")
  750. assert _run(
  751. "backup-plan", "--backup-dir", str(source),
  752. "--release-artifact", str(data), "--config-artifact", str(data),
  753. "--key-reference", "kms://enterprise/production-backup",
  754. "--encryption-provider", "external", "--environment", "production", expect=2,
  755. )["status"] == "rejected"
  756. def test_restore_rejects_reused_new_target(tmp_path: Path):
  757. source = tmp_path / "replay-backup"
  758. source.mkdir(mode=0o700)
  759. data = source / "postgres.sql"
  760. data.write_text("backup")
  761. manifest = _run(
  762. "backup-plan", "--backup-dir", str(source),
  763. "--release-artifact", str(data), "--config-artifact", str(data),
  764. "--key-reference", "kms://external/backup", "--encryption-provider", "fake",
  765. "--test-only",
  766. )
  767. args = (
  768. "restore-plan", "--backup-manifest", manifest["manifest_path"],
  769. "--target", "recovery-once", "--confirm-target", "recovery-once",
  770. "--environment", "test",
  771. "--test-only",
  772. )
  773. assert _run(*args)["status"] == "restore_to_new_target_planned"
  774. assert _run(*args, expect=2)["status"] == "rejected"
  775. def test_restore_rejects_manifest_supplied_public_key(tmp_path: Path):
  776. source = tmp_path / "self-signed-backup"
  777. source.mkdir(mode=0o700)
  778. data = source / "postgres.sql"
  779. data.write_text("backup")
  780. manifest = _run(
  781. "backup-plan", "--backup-dir", str(source),
  782. "--release-artifact", str(data), "--config-artifact", str(data),
  783. "--key-reference", "kms://external/backup", "--encryption-provider", "fake",
  784. "--test-only",
  785. )
  786. manifest_path = Path(str(manifest["manifest_path"]))
  787. value = json.loads(manifest_path.read_text())
  788. value["integrity_signature"]["public_key"] = "00" * 32
  789. manifest_path.write_text(json.dumps(value))
  790. assert _run(
  791. "restore-plan", "--backup-manifest", str(manifest_path),
  792. "--target", "self-signed-target", "--confirm-target", "self-signed-target",
  793. "--environment", "test", "--test-only", expect=2,
  794. )["status"] == "rejected"
  795. def test_restore_rejects_registry_key_b_when_manifest_names_key_a(
  796. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  797. ):
  798. _temporary_registry(tmp_path, monkeypatch)
  799. source = tmp_path / "key-swap-backup"
  800. source.mkdir(mode=0o700)
  801. data = source / "postgres.sql"
  802. data.write_text("backup")
  803. backup_args = DELIVERY.parser().parse_args([
  804. "backup-plan", "--backup-dir", str(source),
  805. "--release-artifact", str(data), "--config-artifact", str(data),
  806. "--key-reference", "kms://external/backup", "--encryption-provider", "fake",
  807. "--test-only",
  808. ])
  809. manifest = DELIVERY.command_backup(backup_args)
  810. manifest_path = Path(str(manifest["manifest_path"]))
  811. value = json.loads(manifest_path.read_text())
  812. payload = {
  813. "manifest_sha256": value["manifest_sha256"],
  814. "encryption_provider": value["encryption_provider"],
  815. "key_reference": value["key_reference"],
  816. }
  817. value["integrity_signature"]["key_id"] = "key-b"
  818. value["integrity_signature"]["signature"] = Ed25519PrivateKey.from_private_bytes(
  819. SECOND_PRIVATE_KEY
  820. ).sign(DELIVERY.canonical(payload)).hex()
  821. manifest_path.write_text(json.dumps(value))
  822. restore_args = DELIVERY.parser().parse_args([
  823. "restore-plan", "--backup-manifest", str(manifest_path),
  824. "--target", "key-swap-target", "--confirm-target", "key-swap-target",
  825. "--environment", "test", "--test-only",
  826. ])
  827. with pytest.raises(DELIVERY.DeliveryError, match="signer identity mismatch"):
  828. DELIVERY.command_restore(restore_args)
  829. def test_compatibility_recomputes_live_release_inputs(tmp_path: Path):
  830. artifact = tmp_path / "release.tar.gz"
  831. artifact.write_bytes(b"release")
  832. evidence = _evidence(tmp_path / "compatibility-evidence", b"release")
  833. contracts = {}
  834. for name in ("base", "candidate", "rollback"):
  835. path = tmp_path / f"{name}-contract.json"
  836. path.write_text(json.dumps(_contract_manifest(path, artifact, evidence["sbom"])))
  837. contracts[name] = path
  838. report = _run(
  839. "compatibility",
  840. "--output",
  841. str(tmp_path / "matrix.json"),
  842. "--base-version",
  843. "20260811",
  844. "--candidate-version",
  845. "20260812",
  846. "--base-artifact", str(artifact), "--base-sbom", str(evidence["sbom"]),
  847. "--candidate-artifact", str(artifact), "--candidate-sbom", str(evidence["sbom"]),
  848. "--rollback-artifact", str(artifact), "--rollback-sbom", str(evidence["sbom"]),
  849. "--base-contract", str(contracts["base"]),
  850. "--candidate-contract", str(contracts["candidate"]),
  851. "--rollback-contract", str(contracts["rollback"]),
  852. )
  853. assert report["status"] == "compatible"
  854. matrix = json.loads((tmp_path / "matrix.json").read_text())
  855. assert matrix["migration_head"] == "20260811_495"
  856. assert matrix["openapi_sha256"] == _sha(ROOT / "docs/architecture/OPENAPI.yaml")
  857. assert matrix["chart_sha256"]
  858. assert matrix["rollback"]["artifact_sha256"] == _sha(artifact)
  859. assert matrix["comparison"]["base_to_candidate"] == "verified"
  860. incompatible = tmp_path / "incompatible.tar.gz"
  861. incompatible.write_bytes(b"totally unrelated")
  862. incompatible_evidence = _evidence(tmp_path / "incompatible-evidence", b"totally unrelated")
  863. changed_contract = tmp_path / "changed-contract.json"
  864. changed_contract.write_text(
  865. json.dumps(_contract_manifest(changed_contract, incompatible, incompatible_evidence["sbom"]))
  866. )
  867. assert _run(
  868. "compatibility", "--output", str(tmp_path / "rejected.json"),
  869. "--base-version", "20260811", "--candidate-version", "20260812",
  870. "--base-artifact", str(artifact), "--base-sbom", str(evidence["sbom"]),
  871. "--candidate-artifact", str(incompatible), "--candidate-sbom", str(incompatible_evidence["sbom"]),
  872. "--rollback-artifact", str(artifact), "--rollback-sbom", str(evidence["sbom"]),
  873. "--base-contract", str(contracts["base"]),
  874. "--candidate-contract", str(changed_contract),
  875. "--rollback-contract", str(contracts["rollback"]),
  876. expect=2,
  877. )["status"] == "rejected"
  878. base_openapi = tmp_path / "base-openapi.yaml"
  879. candidate_openapi = tmp_path / "candidate-openapi.yaml"
  880. base_openapi.write_text("openapi: 3.0.0\npaths:\n /kept:\n get: {}\n")
  881. candidate_openapi.write_text("openapi: 3.0.0\npaths:\n /new:\n get: {}\n")
  882. base_contract = _contract_manifest(
  883. tmp_path / "base-contract.json", artifact, evidence["sbom"], openapi=base_openapi
  884. )
  885. for name, value in {
  886. "base": base_contract,
  887. "candidate": {**base_contract, "openapi": str(candidate_openapi)},
  888. "rollback": base_contract,
  889. }.items():
  890. (tmp_path / f"{name}-contract.json").write_text(json.dumps(value))
  891. assert _run(
  892. "compatibility", "--output", str(tmp_path / "api-rejected.json"),
  893. "--base-version", "20260811", "--candidate-version", "20260812",
  894. "--base-artifact", str(artifact), "--base-sbom", str(evidence["sbom"]),
  895. "--candidate-artifact", str(artifact), "--candidate-sbom", str(evidence["sbom"]),
  896. "--rollback-artifact", str(artifact), "--rollback-sbom", str(evidence["sbom"]),
  897. "--base-contract", str(tmp_path / "base-contract.json"),
  898. "--candidate-contract", str(tmp_path / "candidate-contract.json"),
  899. "--rollback-contract", str(tmp_path / "rollback-contract.json"),
  900. expect=2,
  901. )["status"] == "rejected"