wp06_postgres_identities.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. """Ephemeral, real PostgreSQL identities for WP06 migration/runtime tests.
  2. The administrator is deliberately limited to test setup and teardown. The
  3. actual Alembic process runs as a fresh LOGIN, NOSUPERUSER, NOCREATEROLE
  4. migrator. The runtime login has only the runtime group and must
  5. use the SECURITY DEFINER gateways.
  6. """
  7. from __future__ import annotations
  8. import os
  9. import subprocess
  10. import uuid
  11. from dataclasses import dataclass
  12. from pathlib import Path
  13. import pytest
  14. from sqlalchemy import create_engine, text
  15. from sqlalchemy.engine import make_url
  16. ROOT = Path(__file__).resolve().parents[2]
  17. @dataclass(frozen=True)
  18. class Wp06DatabaseIdentities:
  19. migration_url: str
  20. runtime_url: str
  21. migrator: str
  22. runtime: str
  23. def _alembic(url: str, command: str, revision: str) -> None:
  24. environment = dict(os.environ, MIGRATION_DATABASE_URL=url)
  25. result = subprocess.run(
  26. [str(ROOT / ".venv/bin/alembic"), "-c", str(ROOT / "alembic.ini"), command, revision],
  27. cwd=ROOT, env=environment, capture_output=True, text=True,
  28. )
  29. if result.returncode:
  30. raise AssertionError(result.stderr)
  31. def _quoted(connection, value: str) -> str:
  32. return connection.dialect.identifier_preparer.quote(value)
  33. @pytest.fixture(scope="session")
  34. def wp06_postgres_identities() -> Wp06DatabaseIdentities:
  35. """Create and remove isolated low-privilege WP06 test logins.
  36. ``TEST_DATABASE_URL`` is a DBA-only bootstrap input. It is never passed
  37. to Alembic or to a runtime repository during this fixture's lifetime.
  38. """
  39. administrator_url = os.environ.get("TEST_DATABASE_URL")
  40. if not administrator_url:
  41. pytest.skip("TEST_DATABASE_URL is required")
  42. suffix = uuid.uuid4().hex[:12]
  43. migrator = f"wp06_migrator_{suffix}"
  44. runtime = f"wp06_runtime_{suffix}"
  45. password = f"wp06_{uuid.uuid4().hex}"
  46. admin_engine = create_engine(administrator_url, pool_pre_ping=True)
  47. original_migration = os.environ.get("TEST_MIGRATION_DATABASE_URL")
  48. original_runtime = os.environ.get("TEST_RUNTIME_DATABASE_URL")
  49. try:
  50. with admin_engine.begin() as connection:
  51. quoted_migrator = _quoted(connection, migrator)
  52. quoted_runtime = _quoted(connection, runtime)
  53. connection.execute(text(
  54. f"CREATE ROLE {quoted_migrator} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :password"
  55. ), {"password": password})
  56. connection.execute(text(
  57. f"CREATE ROLE {quoted_runtime} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :password"
  58. ), {"password": password})
  59. # DBA setup only: stable roles, memberships, and the ownership
  60. # prerequisites that a real deployment role-init performs.
  61. connection.execute(text(
  62. f"GRANT dataops_edge_evidence_owner, dataops_trusted_delivery_owner, dataops_trusted_delivery_writer, dataops_agent_runtime_owner, dataops_tenant_foundation_owner, dataops_tenant_control TO {quoted_migrator}"
  63. ))
  64. connection.execute(text(f"GRANT dataops_app_runtime, dataops_agent_runtime TO {quoted_runtime}"))
  65. connection.execute(text(
  66. "GRANT USAGE, CREATE ON SCHEMA public TO dataops_trusted_delivery_owner, dataops_trusted_delivery_writer, dataops_agent_runtime_owner, dataops_tenant_foundation_owner"
  67. ))
  68. connection.execute(text("GRANT USAGE ON SCHEMA public TO dataops_agent_runtime"))
  69. connection.execute(text(
  70. f"GRANT REFERENCES ON TABLE public.users, public.governed_agents TO {quoted_migrator}"
  71. ))
  72. # The no-superuser migrator needs only FK-definition privileges on
  73. # pre-existing P3 tables. It still receives no data DML/DDL owner
  74. # escape hatch outside the dedicated owner/writer memberships.
  75. connection.execute(text(
  76. f"GRANT REFERENCES ON TABLE public.data_incidents, public.data_observability_alerts, public.governance_tasks, public.agent_credentials TO {quoted_migrator}"
  77. ))
  78. connection.execute(text(f"""
  79. GRANT SELECT, UPDATE ON TABLE public.governance_tasks TO {quoted_migrator} WITH GRANT OPTION;
  80. GRANT SELECT ON TABLE public.governance_task_reviews,
  81. public.agent_tool_grants, public.governed_agents TO {quoted_migrator} WITH GRANT OPTION
  82. ; GRANT SELECT, UPDATE ON TABLE public.agent_credentials TO {quoted_migrator} WITH GRANT OPTION
  83. """))
  84. roles = connection.execute(text("""
  85. SELECT rolname, rolsuper, rolcreaterole
  86. FROM pg_roles WHERE rolname IN (:migrator, :runtime)
  87. """), {"migrator": migrator, "runtime": runtime}).mappings().all()
  88. assert {(row["rolname"], row["rolsuper"], row["rolcreaterole"]) for row in roles} == {
  89. (migrator, False, False), (runtime, False, False)
  90. }
  91. parsed = make_url(administrator_url)
  92. migration_url = parsed.set(username=migrator, password=password).render_as_string(hide_password=False)
  93. runtime_url = parsed.set(username=runtime, password=password).render_as_string(hide_password=False)
  94. os.environ["TEST_MIGRATION_DATABASE_URL"] = migration_url
  95. os.environ["TEST_RUNTIME_DATABASE_URL"] = runtime_url
  96. yield Wp06DatabaseIdentities(migration_url, runtime_url, migrator, runtime)
  97. finally:
  98. if original_migration is None:
  99. os.environ.pop("TEST_MIGRATION_DATABASE_URL", None)
  100. else:
  101. os.environ["TEST_MIGRATION_DATABASE_URL"] = original_migration
  102. if original_runtime is None:
  103. os.environ.pop("TEST_RUNTIME_DATABASE_URL", None)
  104. else:
  105. os.environ["TEST_RUNTIME_DATABASE_URL"] = original_runtime
  106. with admin_engine.begin() as connection:
  107. for role in (migrator, runtime):
  108. quoted_role = _quoted(connection, role)
  109. connection.execute(text(f"REASSIGN OWNED BY {quoted_role} TO dataops"))
  110. connection.execute(text(f"DROP OWNED BY {quoted_role}"))
  111. connection.execute(text(f"DROP ROLE IF EXISTS {quoted_role}"))
  112. admin_engine.dispose()
  113. @pytest.fixture(scope="module")
  114. def wp06_head(wp06_postgres_identities) -> Wp06DatabaseIdentities:
  115. """Start each WP06 PostgreSQL module at 480, then exercise 480 -> head.
  116. The DBA-only reset is isolated test teardown/setup. The actual forward
  117. chain is always run by the low-privilege migrator and the fixture leaves
  118. the database at head for later targeted tests.
  119. """
  120. administrator_url = os.environ["TEST_DATABASE_URL"]
  121. _alembic(administrator_url, "downgrade", "20260811_480")
  122. _alembic(wp06_postgres_identities.migration_url, "upgrade", "head")
  123. yield wp06_postgres_identities