test_phase3_wp04_production_runtime.py 13 KB

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