436 lines
18 KiB
Python
436 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import unittest
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import create_engine, event, inspect, text
|
|
from sqlalchemy.engine import Connection, Engine
|
|
from sqlalchemy.exc import DBAPIError
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.schema import CreateSchema, DropSchema
|
|
|
|
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
|
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleContext
|
|
from govoplan_core.core.runtime import clear_runtime, configure_runtime, get_runtime_context
|
|
from govoplan_core.db.base import Base
|
|
|
|
|
|
_CREDENTIAL_ID = "retirement-credential"
|
|
_TENANT_ID = "retirement-tenant"
|
|
_PRIVATE_FIXTURE_VALUES = (
|
|
"opaque-password-ciphertext",
|
|
"opaque-token-ciphertext",
|
|
"vault:test-only:credential",
|
|
"private-test-metadata",
|
|
)
|
|
_ROW_COUNT_QUERIES = {
|
|
"audit_log": text("SELECT count(*) FROM audit_log"),
|
|
"core_change_sequence": text("SELECT count(*) FROM core_change_sequence"),
|
|
"retirement_scrub_probe": text("SELECT count(*) FROM retirement_scrub_probe"),
|
|
}
|
|
|
|
|
|
def _postgres_database_url() -> str:
|
|
candidate = (
|
|
os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL")
|
|
or os.environ.get("DATABASE_URL")
|
|
or ""
|
|
).strip()
|
|
return candidate if candidate.startswith("postgresql") else ""
|
|
|
|
|
|
def _proof_is_required() -> bool:
|
|
return os.environ.get("GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF", "").strip().casefold() in {
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
"on",
|
|
}
|
|
|
|
|
|
class _AuditCapabilityRegistry:
|
|
def __init__(self, recorder: object) -> None:
|
|
self._recorder = recorder
|
|
|
|
def has_capability(self, name: str) -> bool:
|
|
return name == CAPABILITY_AUDIT_RECORDER
|
|
|
|
def require_capability(self, name: str) -> object:
|
|
if not self.has_capability(name):
|
|
raise LookupError(name)
|
|
return self._recorder
|
|
|
|
|
|
class PostgreSQLRetirementAtomicityTests(unittest.TestCase):
|
|
"""Production-reference proof for destructive module retirement.
|
|
|
|
Every test owns a random PostgreSQL schema and removes it afterward. The
|
|
fixture stores only synthetic credential material. Assertions and audit
|
|
inspection expose booleans and allow-listed metadata, never stored values
|
|
or the database URL.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
database_url = _postgres_database_url()
|
|
if not database_url:
|
|
if _proof_is_required():
|
|
self.fail("the required PostgreSQL retirement proof has no PostgreSQL database")
|
|
self.skipTest("a PostgreSQL integration database is not configured")
|
|
|
|
try:
|
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
|
from govoplan_audit.backend.db.models import AuditLog
|
|
from govoplan_audit.backend.recording import SqlAuditRecorder
|
|
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
|
from govoplan_files.backend.manifest import manifest as files_manifest
|
|
except ModuleNotFoundError as exc:
|
|
if _proof_is_required():
|
|
self.fail(f"the required PostgreSQL retirement proof is missing package: {exc.name}")
|
|
self.skipTest(f"the full-stack integration packages are not installed: {exc.name}")
|
|
|
|
self.audit_log_model = AuditLog
|
|
self.credential_model = FileConnectorCredential
|
|
self.profile_model = FileConnectorProfile
|
|
self.files_manifest = files_manifest
|
|
self.previous_runtime = get_runtime_context()
|
|
self.admin_engine: Engine | None = None
|
|
self.engine: Engine | None = None
|
|
self.schema_name = f"govoplan_retirement_{uuid4().hex}"
|
|
self.capture_retirement_ddl = False
|
|
self.retirement_ddl_backend_pids: list[int] = []
|
|
self.ddl_listener_registered = False
|
|
|
|
try:
|
|
self.admin_engine = create_engine(database_url, pool_pre_ping=True)
|
|
with self.admin_engine.begin() as connection:
|
|
if connection.dialect.name != "postgresql":
|
|
self.skipTest("the configured integration database is not PostgreSQL")
|
|
connection.execute(CreateSchema(self.schema_name))
|
|
|
|
self.engine = create_engine(
|
|
database_url,
|
|
pool_pre_ping=True,
|
|
pool_size=5,
|
|
max_overflow=0,
|
|
connect_args={
|
|
"options": (
|
|
f"-csearch_path={self.schema_name},pg_catalog "
|
|
"-clock_timeout=500ms -cstatement_timeout=10s "
|
|
"-capplication_name=govoplan-retirement-atomicity"
|
|
),
|
|
},
|
|
)
|
|
event.listen(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
|
self.ddl_listener_registered = True
|
|
self._create_fixture_schema()
|
|
self._seed_credential()
|
|
|
|
recorder = SqlAuditRecorder()
|
|
configure_runtime(ModuleContext(registry=_AuditCapabilityRegistry(recorder), settings=object()))
|
|
except Exception:
|
|
self._cleanup()
|
|
raise
|
|
|
|
def tearDown(self) -> None:
|
|
self._cleanup()
|
|
|
|
def test_scrub_audit_and_table_retirement_commit_together(self) -> None:
|
|
assert self.engine is not None
|
|
with Session(self.engine) as session:
|
|
installer_backend_pid = self._session_backend_pid(session)
|
|
plan = self._retirement_plan(session)
|
|
self._execute_retirement(session, plan)
|
|
session.commit()
|
|
|
|
self.assertTrue(self.retirement_ddl_backend_pids, "the retirement provider emitted no DROP TABLE statement")
|
|
self.assertEqual(
|
|
{installer_backend_pid},
|
|
set(self.retirement_ddl_backend_pids),
|
|
"destructive DDL did not use the installer Session connection",
|
|
)
|
|
with self.engine.connect() as connection:
|
|
self.assertFalse(inspect(connection).has_table(self.credential_model.__tablename__))
|
|
self.assertFalse(inspect(connection).has_table(self.profile_model.__tablename__))
|
|
|
|
probe = connection.execute(text(
|
|
"SELECT password_removed, token_removed, username_removed, "
|
|
"secret_reference_removed, metadata_removed "
|
|
"FROM retirement_scrub_probe"
|
|
)).mappings().one()
|
|
self.assertTrue(all(probe.values()), "the credential scrub probe did not observe every removal")
|
|
|
|
audit_rows = connection.execute(text(
|
|
"SELECT action, object_type, object_id, details "
|
|
"FROM audit_log ORDER BY created_at, id"
|
|
)).mappings().all()
|
|
self.assertEqual(1, len(audit_rows))
|
|
audit_row = audit_rows[0]
|
|
self.assertEqual("files.connector_credential_deleted", audit_row["action"])
|
|
self.assertEqual("file_connector_credential", audit_row["object_type"])
|
|
self.assertEqual(_CREDENTIAL_ID, audit_row["object_id"])
|
|
self._assert_non_secret_audit_details(dict(audit_row["details"] or {}))
|
|
self.assertEqual(1, self._row_count(connection, "core_change_sequence"))
|
|
|
|
def test_database_audit_failure_rolls_back_scrub_and_audit_state(self) -> None:
|
|
assert self.engine is not None
|
|
with self.engine.begin() as connection:
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE FUNCTION reject_retirement_audit() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF NEW.action = 'files.connector_credential_deleted' THEN
|
|
RAISE EXCEPTION 'injected retirement audit failure';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
"""
|
|
)
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE TRIGGER reject_retirement_audit_insert
|
|
BEFORE INSERT ON audit_log
|
|
FOR EACH ROW EXECUTE FUNCTION reject_retirement_audit()
|
|
"""
|
|
)
|
|
|
|
with Session(self.engine) as session:
|
|
self._session_backend_pid(session)
|
|
plan = self._retirement_plan(session)
|
|
with self.assertRaises(DBAPIError) as caught:
|
|
self._execute_retirement(session, plan)
|
|
self.assertEqual("P0001", getattr(caught.exception.orig, "sqlstate", None))
|
|
session.rollback()
|
|
|
|
self.assertEqual([], self.retirement_ddl_backend_pids, "DDL ran after the injected audit failure")
|
|
self._assert_failed_retirement_rolled_back()
|
|
|
|
def test_database_ddl_failure_rolls_back_scrub_audit_and_prior_drop(self) -> None:
|
|
assert self.engine is not None
|
|
with self.engine.begin() as connection:
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE TABLE retirement_drop_guard (
|
|
id bigint PRIMARY KEY,
|
|
credential_id varchar(255) NOT NULL
|
|
REFERENCES file_connector_credentials(id)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
text("INSERT INTO retirement_drop_guard (id, credential_id) VALUES (1, :credential_id)"),
|
|
{"credential_id": _CREDENTIAL_ID},
|
|
)
|
|
|
|
with Session(self.engine) as session:
|
|
installer_backend_pid = self._session_backend_pid(session)
|
|
plan = self._retirement_plan(session)
|
|
with self.assertRaises(DBAPIError) as caught:
|
|
self._execute_retirement(session, plan)
|
|
self.assertEqual("2BP01", getattr(caught.exception.orig, "sqlstate", None))
|
|
session.rollback()
|
|
|
|
self.assertTrue(self.retirement_ddl_backend_pids, "the injected DDL failure was not reached")
|
|
self.assertEqual(
|
|
{installer_backend_pid},
|
|
set(self.retirement_ddl_backend_pids),
|
|
"destructive DDL did not use the installer Session connection",
|
|
)
|
|
self._assert_failed_retirement_rolled_back()
|
|
|
|
def _create_fixture_schema(self) -> None:
|
|
assert self.engine is not None
|
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|
|
|
with self.engine.begin() as connection:
|
|
# Audit and Files reference Access actors. The proof does not need
|
|
# actor rows, so small FK targets keep its schema bounded.
|
|
connection.exec_driver_sql("CREATE TABLE access_users (id varchar(36) PRIMARY KEY)")
|
|
connection.exec_driver_sql("CREATE TABLE access_api_keys (id varchar(36) PRIMARY KEY)")
|
|
Base.metadata.create_all(
|
|
bind=connection,
|
|
tables=[
|
|
ChangeSequenceEntry.__table__,
|
|
self.audit_log_model.__table__,
|
|
self.credential_model.__table__,
|
|
self.profile_model.__table__,
|
|
],
|
|
)
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE TABLE retirement_scrub_probe (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
password_removed boolean NOT NULL,
|
|
token_removed boolean NOT NULL,
|
|
username_removed boolean NOT NULL,
|
|
secret_reference_removed boolean NOT NULL,
|
|
metadata_removed boolean NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE FUNCTION record_retirement_scrub() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
INSERT INTO retirement_scrub_probe (
|
|
password_removed,
|
|
token_removed,
|
|
username_removed,
|
|
secret_reference_removed,
|
|
metadata_removed
|
|
) VALUES (
|
|
OLD.password_encrypted IS NOT NULL AND NEW.password_encrypted IS NULL,
|
|
OLD.token_encrypted IS NOT NULL AND NEW.token_encrypted IS NULL,
|
|
OLD.username IS NOT NULL AND NEW.username IS NULL,
|
|
OLD.secret_ref IS NOT NULL AND NEW.secret_ref IS NULL,
|
|
OLD.metadata IS NOT NULL AND NEW.metadata::jsonb = '{}'::jsonb
|
|
);
|
|
RETURN NEW;
|
|
END;
|
|
$$
|
|
"""
|
|
)
|
|
connection.exec_driver_sql(
|
|
"""
|
|
CREATE TRIGGER record_retirement_credential_scrub
|
|
BEFORE UPDATE ON file_connector_credentials
|
|
FOR EACH ROW EXECUTE FUNCTION record_retirement_scrub()
|
|
"""
|
|
)
|
|
|
|
def _seed_credential(self) -> None:
|
|
assert self.engine is not None
|
|
with Session(self.engine) as session:
|
|
session.add(self.credential_model(
|
|
id=_CREDENTIAL_ID,
|
|
tenant_id=_TENANT_ID,
|
|
scope_type="tenant",
|
|
scope_id=_TENANT_ID,
|
|
label="Retirement integration credential",
|
|
provider="webdav",
|
|
enabled=True,
|
|
credential_mode="basic",
|
|
username="integration-user",
|
|
password_encrypted=_PRIVATE_FIXTURE_VALUES[0],
|
|
token_encrypted=_PRIVATE_FIXTURE_VALUES[1],
|
|
password_env="INTEGRATION_PASSWORD",
|
|
token_env="INTEGRATION_TOKEN",
|
|
secret_ref=_PRIVATE_FIXTURE_VALUES[2],
|
|
policy={},
|
|
metadata_={"private_hint": _PRIVATE_FIXTURE_VALUES[3]},
|
|
))
|
|
session.commit()
|
|
|
|
def _retirement_plan(self, session: Session) -> MigrationRetirementPlan:
|
|
migration = self.files_manifest.migration_spec
|
|
self.assertIsNotNone(migration)
|
|
assert migration is not None
|
|
self.assertIsNotNone(migration.retirement_provider)
|
|
assert migration.retirement_provider is not None
|
|
plan = migration.retirement_provider(session, "files")
|
|
self.assertTrue(plan.destroy_data_supported)
|
|
self.assertIsNotNone(plan.destroy_data_executor)
|
|
return plan
|
|
|
|
def _execute_retirement(self, session: Session, plan: MigrationRetirementPlan) -> None:
|
|
executor = plan.destroy_data_executor
|
|
self.assertIsNotNone(executor)
|
|
self.capture_retirement_ddl = True
|
|
try:
|
|
executor(session, "files")
|
|
finally:
|
|
self.capture_retirement_ddl = False
|
|
|
|
def _capture_ddl_connection(
|
|
self,
|
|
connection: Connection,
|
|
_cursor: object,
|
|
statement: str,
|
|
_parameters: object,
|
|
_context: object,
|
|
_executemany: bool,
|
|
) -> None:
|
|
if not self.capture_retirement_ddl or not statement.lstrip().upper().startswith("DROP TABLE"):
|
|
return
|
|
self.retirement_ddl_backend_pids.append(self._connection_backend_pid(connection))
|
|
|
|
def _session_backend_pid(self, session: Session) -> int:
|
|
connection = session.connection()
|
|
sql_pid = int(connection.execute(text("SELECT pg_backend_pid()")).scalar_one())
|
|
driver_pid = self._connection_backend_pid(connection)
|
|
self.assertEqual(sql_pid, driver_pid)
|
|
return sql_pid
|
|
|
|
@staticmethod
|
|
def _connection_backend_pid(connection: Connection) -> int:
|
|
driver_connection = connection.connection.driver_connection
|
|
return int(driver_connection.info.backend_pid)
|
|
|
|
def _assert_failed_retirement_rolled_back(self) -> None:
|
|
assert self.engine is not None
|
|
with self.engine.connect() as connection:
|
|
inspector = inspect(connection)
|
|
self.assertTrue(inspector.has_table(self.credential_model.__tablename__))
|
|
self.assertTrue(inspector.has_table(self.profile_model.__tablename__))
|
|
state = connection.execute(
|
|
text(
|
|
"SELECT enabled, password_encrypted IS NOT NULL AS has_password, "
|
|
"token_encrypted IS NOT NULL AS has_token, username IS NOT NULL AS has_username, "
|
|
"secret_ref IS NOT NULL AS has_secret_reference, metadata::jsonb <> '{}'::jsonb AS has_metadata "
|
|
"FROM file_connector_credentials WHERE id = :credential_id"
|
|
),
|
|
{"credential_id": _CREDENTIAL_ID},
|
|
).mappings().one()
|
|
self.assertTrue(all(state.values()), "credential state was not fully restored")
|
|
self.assertEqual(0, self._row_count(connection, "retirement_scrub_probe"))
|
|
self.assertEqual(0, self._row_count(connection, "audit_log"))
|
|
self.assertEqual(0, self._row_count(connection, "core_change_sequence"))
|
|
|
|
def _assert_non_secret_audit_details(self, details: dict[str, object]) -> None:
|
|
self.assertEqual("module_data_retired", details.get("deletion_reason"))
|
|
self.assertEqual("unowned_external_reference_detached", details.get("storage_backend"))
|
|
self.assertEqual("<redacted>", details.get("deleted_secret_kinds"))
|
|
self.assertEqual(
|
|
["password_env", "token_env", "unowned_external_secret_ref"],
|
|
details.get("removed_reference_kinds"),
|
|
)
|
|
self.assertTrue(details.get("removed_metadata"))
|
|
serialized = json.dumps(details, sort_keys=True)
|
|
self.assertFalse(
|
|
any(private_value in serialized for private_value in _PRIVATE_FIXTURE_VALUES),
|
|
"audit diagnostics contain fixture-private material",
|
|
)
|
|
|
|
@staticmethod
|
|
def _row_count(connection: Connection, table_name: str) -> int:
|
|
query = _ROW_COUNT_QUERIES.get(table_name)
|
|
if query is None:
|
|
raise ValueError("unsupported retirement proof table")
|
|
return int(connection.execute(query).scalar_one())
|
|
|
|
def _cleanup(self) -> None:
|
|
if getattr(self, "previous_runtime", None) is None:
|
|
clear_runtime()
|
|
else:
|
|
configure_runtime(self.previous_runtime)
|
|
if self.engine is not None:
|
|
if self.ddl_listener_registered:
|
|
event.remove(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
|
self.engine.dispose()
|
|
self.engine = None
|
|
if self.admin_engine is not None:
|
|
try:
|
|
with self.admin_engine.begin() as connection:
|
|
connection.execute(DropSchema(self.schema_name, cascade=True, if_exists=True))
|
|
finally:
|
|
self.admin_engine.dispose()
|
|
self.admin_engine = None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|