test_phase3_wp04_production_runtime.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import subprocess
  2. from pathlib import Path
  3. from types import SimpleNamespace
  4. import pytest
  5. from cryptography.hazmat.primitives import serialization
  6. from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
  7. from flask import Flask
  8. from app.config.config import ProductionConfig, apply_runtime_env_config
  9. from app.config.database_urls import (
  10. load_and_validate_database_env,
  11. validate_database_environment,
  12. validate_postgresql_url,
  13. )
  14. from app.core.edge_gateway.runtime_roles import provision_runtime_login
  15. from app.core.edge_gateway.service import (
  16. EdgeGatewayConfigurationError,
  17. EdgeGatewayService,
  18. )
  19. ROOT = Path(__file__).resolve().parents[1]
  20. PRODUCTION_ENV = {
  21. "DATABASE_URL": "postgresql://dataops_app:runtime-secret@db.example.test/dataops",
  22. "NEO4J_URI": "bolt+s://neo4j.example.test:7687",
  23. "NEO4J_HTTP_URI": "https://neo4j.example.test:7473",
  24. "NEO4J_USER": "dataops_graph",
  25. "NEO4J_PASSWORD": "graph-secret",
  26. "MINIO_HOST": "objects.example.test:9000",
  27. "MINIO_USER": "dataops_objects",
  28. "MINIO_PASSWORD": "object-secret",
  29. "MINIO_BUCKET": "dataops-production",
  30. }
  31. def _production_app(monkeypatch, overrides=None):
  32. monkeypatch.setenv("FLASK_ENV", "production")
  33. monkeypatch.setenv("APP_ENV_FILE", "/nonexistent/dataops-test.env")
  34. for name, value in {**PRODUCTION_ENV, **(overrides or {})}.items():
  35. if value is None:
  36. monkeypatch.delenv(name, raising=False)
  37. else:
  38. monkeypatch.setenv(name, value)
  39. app = Flask(__name__)
  40. app.config.from_object(ProductionConfig)
  41. return app
  42. @pytest.mark.parametrize(
  43. ("name", "value"),
  44. [
  45. ("DATABASE_URL", None),
  46. ("DATABASE_URL", "postgresql://dataops_user:replace-password@127.0.0.1/dataops"),
  47. ("NEO4J_PASSWORD", None),
  48. ("NEO4J_PASSWORD", "replace-neo4j-password"),
  49. ("MINIO_USER", None),
  50. ("MINIO_PASSWORD", "replace-minio-password"),
  51. ],
  52. )
  53. def test_production_service_configuration_fails_closed(monkeypatch, name, value):
  54. app = _production_app(monkeypatch, {name: value})
  55. with pytest.raises(RuntimeError, match=name):
  56. apply_runtime_env_config(app)
  57. def test_production_configuration_accepts_complete_non_placeholder_values(monkeypatch):
  58. app = _production_app(monkeypatch)
  59. apply_runtime_env_config(app)
  60. assert app.config["SQLALCHEMY_DATABASE_URI"] == PRODUCTION_ENV["DATABASE_URL"]
  61. assert app.config["NEO4J_PASSWORD"] == PRODUCTION_ENV["NEO4J_PASSWORD"]
  62. assert app.config["MINIO_PASSWORD"] == PRODUCTION_ENV["MINIO_PASSWORD"]
  63. def test_runtime_and_migrator_images_are_capability_separated():
  64. backend = (ROOT / "deploy/docker/backend.Dockerfile").read_text()
  65. runner = (ROOT / "deploy/docker/runner.Dockerfile").read_text()
  66. compose = (ROOT / "deploy/docker/docker-compose.yml").read_text()
  67. migration = (ROOT / "migrations/versions/20260809_477_edge_gateway_control_plane.py").read_text()
  68. provisioner = (ROOT / "app/core/edge_gateway/runtime_roles.py").read_text()
  69. assert "AS migrator" in backend and "AS runtime" in backend
  70. runtime = backend.split("AS runtime", 1)[1]
  71. assert "COPY migrations/" not in runtime and "COPY alembic.ini" not in runtime
  72. assert "pip uninstall -y alembic" in runtime
  73. assert "COPY migrations/" not in runner and "COPY alembic.ini" not in runner
  74. assert "pip uninstall -y alembic" in runner
  75. assert "target: migrator" in compose and "target: runtime" in compose
  76. assert "DB_ROLE_INIT_DATABASE_URL" in compose
  77. assert "DB_ROLE_INIT_DATABASE_URL" in provisioner
  78. assert "MIGRATION_DATABASE_URL" in provisioner
  79. role_bootstrap = migration.split("CREATE OR REPLACE FUNCTION", 1)[0]
  80. assert "CREATE ROLE" not in role_bootstrap and "ALTER ROLE" not in role_bootstrap
  81. def test_enterprise_edge_mtls_proxy_is_dedicated_and_fail_closed():
  82. compose = (ROOT / "deploy/docker/docker-compose.yml").read_text()
  83. container_nginx = (ROOT / "deploy/docker/edge-mtls-nginx.conf").read_text()
  84. host_nginx = (ROOT / "deployment/config/nginx-dataops-platform.conf").read_text()
  85. assert "edge-mtls-proxy:" in compose
  86. edge_service = compose.split("edge-mtls-proxy:", 1)[1].split(
  87. "\n lightrag-neo4j:", 1
  88. )[0]
  89. assert "enterprise-edge" in edge_service
  90. assert "edge-control-net" in edge_service
  91. assert "ipv4_address: 172.31.0.10" in edge_service
  92. assert "EDGE_MTLS_TRUSTED_PROXY_IPS: 172.31.0.10" in compose
  93. assert "EDGE_GATEWAY_SIGNING_PRIVATE_KEY_FILE: /run/secrets/edge_gateway_signing_private_key" in compose
  94. assert "EDGE_GATEWAY_SIGNING_PRIVATE_KEY:" not in compose
  95. assert "edge_gateway_signing_private_key:" in compose
  96. assert "/run/secrets/edge_gateway_signing_private_key" in compose
  97. assert "ssl_verify_client on;" in container_nginx
  98. assert "ssl_client_certificate /etc/nginx/edge-mtls/client-ca.pem;" in container_nginx
  99. assert "ssl_crl /etc/nginx/edge-mtls/client-ca.crl;" in container_nginx
  100. assert "location /api/datasource/edge/" in container_nginx
  101. assert (
  102. "proxy_set_header X-DataOps-Edge-Client-Cert $ssl_client_escaped_cert;"
  103. in container_nginx
  104. )
  105. assert (
  106. "proxy_set_header X-DataOps-Edge-Client-Verify $ssl_client_verify;"
  107. in container_nginx
  108. )
  109. assert "listen 18443 ssl;" in host_nginx
  110. assert "ssl_verify_client on;" in host_nginx
  111. assert "edge-client-ca.crt" in host_nginx
  112. assert "edge-client-ca.crl" in host_nginx
  113. def test_production_edge_signer_uses_secure_file_or_provider_and_rejects_inline(tmp_path):
  114. private_key = Ed25519PrivateKey.generate()
  115. private_path = tmp_path / "edge-signing.key"
  116. private_path.write_bytes(
  117. private_key.private_bytes(
  118. serialization.Encoding.PEM,
  119. serialization.PrivateFormat.PKCS8,
  120. serialization.NoEncryption(),
  121. )
  122. )
  123. private_path.chmod(0o600)
  124. public_key = private_key.public_key().public_bytes(
  125. serialization.Encoding.Raw,
  126. serialization.PublicFormat.Raw,
  127. ).hex()
  128. repository = SimpleNamespace(rollback=lambda: None)
  129. service = EdgeGatewayService(
  130. repository,
  131. signing_private_key_file=str(private_path),
  132. signing_public_key=public_key,
  133. signing_key_id="edge-key-2026-08",
  134. production=True,
  135. )
  136. assert service._require_signer()[1] == "edge-key-2026-08"
  137. provider_service = EdgeGatewayService(
  138. repository,
  139. signing_key_provider=lambda: private_path.read_bytes(),
  140. signing_public_key=public_key,
  141. signing_key_id="edge-key-2026-08",
  142. production=True,
  143. )
  144. assert provider_service._require_signer()[0].public_key().public_bytes(
  145. serialization.Encoding.Raw,
  146. serialization.PublicFormat.Raw,
  147. ).hex() == public_key
  148. with pytest.raises(EdgeGatewayConfigurationError, match="inline"):
  149. EdgeGatewayService(
  150. repository,
  151. signing_private_key=private_key,
  152. signing_public_key=public_key,
  153. signing_key_id="edge-key-2026-08",
  154. production=True,
  155. )
  156. private_path.chmod(0o640)
  157. with pytest.raises(EdgeGatewayConfigurationError, match="permissions"):
  158. EdgeGatewayService(
  159. repository,
  160. signing_private_key_file=str(private_path),
  161. signing_public_key=public_key,
  162. signing_key_id="edge-key-2026-08",
  163. production=True,
  164. )
  165. private_path.chmod(0o600)
  166. with pytest.raises(EdgeGatewayConfigurationError, match="public key"):
  167. EdgeGatewayService(
  168. repository,
  169. signing_private_key_file=str(private_path),
  170. signing_public_key="0" * 64,
  171. signing_key_id="edge-key-2026-08",
  172. production=True,
  173. )
  174. def _template_env() -> dict[str, str]:
  175. values = {}
  176. for raw_line in (ROOT / "deployment/dataops.env").read_text(encoding="utf-8-sig").splitlines():
  177. line = raw_line.strip()
  178. if line and not line.startswith("#") and "=" in line:
  179. name, value = line.split("=", 1)
  180. values[name] = value
  181. return values
  182. def test_checked_in_production_template_is_rejected_everywhere(monkeypatch):
  183. template = _template_env()
  184. app = _production_app(monkeypatch, template)
  185. with pytest.raises(RuntimeError, match="DATABASE_URL"):
  186. apply_runtime_env_config(app)
  187. for name, value in template.items():
  188. monkeypatch.setenv(name, value)
  189. with pytest.raises(RuntimeError, match="DB_ROLE_INIT_DATABASE_URL"):
  190. provision_runtime_login()
  191. with pytest.raises(RuntimeError, match="DB_ROLE_INIT_DATABASE_URL"):
  192. load_and_validate_database_env(ROOT / "deployment/dataops.env")
  193. result = subprocess.run(
  194. [
  195. ".venv/bin/python", "app/config/database_urls.py", "--env-file",
  196. "deployment/dataops.env",
  197. ],
  198. cwd=ROOT,
  199. capture_output=True,
  200. text=True,
  201. )
  202. assert result.returncode != 0
  203. assert "DB_ROLE_INIT_DATABASE_URL" in result.stderr
  204. deploy = (ROOT / "deployment/deploy_dataops.sh").read_text()
  205. source_deploy = (ROOT / "scripts/deploy_dataops.sh").read_text()
  206. assert "database_urls.py" in deploy and "database_urls.py" in source_deploy
  207. @pytest.mark.parametrize(
  208. ("name", "bad_value"),
  209. [
  210. ("DB_ROLE_INIT_DATABASE_URL", "postgresql://role_admin:replace-role-init-password@db/dataops"),
  211. ("MIGRATION_DATABASE_URL", "postgresql://migrator:replace-migration-password@db/dataops"),
  212. ("DATABASE_URL", "postgresql://dataops_app:replace-runtime-password@db/dataops"),
  213. ("DATAOPS_RUNTIME_PASSWORD", "replace-runtime-password"),
  214. ],
  215. )
  216. def test_role_init_rejects_each_database_template_secret(monkeypatch, name, bad_value):
  217. valid = {
  218. "DB_ROLE_INIT_DATABASE_URL": "postgresql://role_admin:Strong%40Role9@db/dataops",
  219. "MIGRATION_DATABASE_URL": "postgresql://migrator:Strong%40Migration9@db/dataops",
  220. "DATABASE_URL": "postgresql://dataops_app:Strong%40Runtime9@db/dataops",
  221. "DATAOPS_MIGRATOR_USER": "dataops_migrator",
  222. "DATAOPS_RUNTIME_USER": "dataops_app",
  223. "DATAOPS_RUNTIME_PASSWORD": "Strong@Runtime9",
  224. }
  225. valid[name] = bad_value
  226. for key, value in valid.items():
  227. monkeypatch.setenv(key, value)
  228. with pytest.raises(RuntimeError, match=name):
  229. provision_runtime_login()
  230. def test_database_url_validation_decodes_components_without_substring_false_positive(tmp_path):
  231. validate_postgresql_url(
  232. "postgresql://dataops_app:S7replace%40middle%21@db.internal/dataops",
  233. "DATABASE_URL",
  234. )
  235. with pytest.raises(RuntimeError, match="DATABASE_URL"):
  236. validate_postgresql_url(
  237. "postgresql://dataops_app:replace%2Druntime%2Dpassword@db.internal/dataops",
  238. "DATABASE_URL",
  239. )
  240. env_file = tmp_path / "dataops.env"
  241. env_file.write_text(
  242. "\n".join([
  243. "DB_ROLE_INIT_DATABASE_URL=postgresql://role_admin:Strong%40Role9@db.internal/dataops",
  244. "MIGRATION_DATABASE_URL=postgresql://migrator:Strong%40Migration9@db.internal/dataops",
  245. "DATABASE_URL=postgresql://dataops_app:Strong%40Runtime9@db.internal/dataops",
  246. "DATAOPS_RUNTIME_PASSWORD=Strong@Runtime9",
  247. ]),
  248. )
  249. load_and_validate_database_env(env_file)
  250. @pytest.mark.parametrize(
  251. "bad_url",
  252. [
  253. "postgresql://:Strong%40Pass9@db.internal/dataops",
  254. "postgresql://replace-user:Strong%40Pass9@db.internal/dataops",
  255. "postgresql://dataops_app:@db.internal/dataops",
  256. "postgresql://dataops_app:Strong%40Pass9@/dataops",
  257. "postgresql://dataops_app:Strong%40Pass9@replace-host/dataops",
  258. "postgresql://dataops_app:Strong%40Pass9@db.internal/",
  259. "postgresql://dataops_app:Strong%40Pass9@db.internal/database_name",
  260. "postgresql://dataops_app:replace%2Dpassword@db.internal/dataops",
  261. ],
  262. )
  263. def test_database_url_validation_rejects_each_empty_or_template_component(bad_url):
  264. with pytest.raises(RuntimeError, match="DATABASE_URL"):
  265. validate_postgresql_url(bad_url, "DATABASE_URL")
  266. def test_shared_environment_validation_compares_decoded_runtime_password():
  267. values = {
  268. "DB_ROLE_INIT_DATABASE_URL": "postgresql://role_admin:Strong%40Role9@db.internal/dataops",
  269. "MIGRATION_DATABASE_URL": "postgresql://migrator:Strong%40Migration9@db.internal/dataops",
  270. "DATABASE_URL": "postgresql://dataops_app:Strong%40Runtime9@db.internal/dataops",
  271. "DATAOPS_RUNTIME_PASSWORD": "Strong@Runtime9",
  272. }
  273. validate_database_environment(values)
  274. values["DATAOPS_RUNTIME_PASSWORD"] = "Different@Runtime9"
  275. with pytest.raises(RuntimeError, match="DATAOPS_RUNTIME_PASSWORD.*match"):
  276. validate_database_environment(values)