fix: establish read-only audit transaction before identity check
This commit is contained in:
@@ -99,6 +99,15 @@ def _load(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn = conn.execution_options(isolation_level="AUTOCOMMIT")
|
conn = conn.execution_options(isolation_level="AUTOCOMMIT")
|
||||||
|
transaction_started = False
|
||||||
|
try:
|
||||||
|
# A fresh connection commonly reports transaction_read_only=off.
|
||||||
|
# Establish the protected transaction on the same connection used
|
||||||
|
# for every factual read before validating a strict audit.
|
||||||
|
if require_read_only:
|
||||||
|
conn.execute(text("BEGIN READ ONLY"))
|
||||||
|
transaction_started = True
|
||||||
|
|
||||||
identity = conn.execute(text(
|
identity = conn.execute(text(
|
||||||
"SELECT current_database(), current_user, current_setting('transaction_read_only')"
|
"SELECT current_database(), current_user, current_setting('transaction_read_only')"
|
||||||
)).one()
|
)).one()
|
||||||
@@ -106,8 +115,13 @@ def _load(
|
|||||||
raise RuntimeError(f"refusing unexpected database identity: {identity!r}")
|
raise RuntimeError(f"refusing unexpected database identity: {identity!r}")
|
||||||
if require_read_only and identity[2] != "on":
|
if require_read_only and identity[2] != "on":
|
||||||
raise RuntimeError(f"read-only simulation requires transaction_read_only=on: {identity!r}")
|
raise RuntimeError(f"read-only simulation requires transaction_read_only=on: {identity!r}")
|
||||||
|
|
||||||
|
# Preserve the development/test path's established ordering: check
|
||||||
|
# its identity first, then protect the factual reads themselves.
|
||||||
|
if not require_read_only:
|
||||||
conn.execute(text("BEGIN READ ONLY"))
|
conn.execute(text("BEGIN READ ONLY"))
|
||||||
try:
|
transaction_started = True
|
||||||
|
|
||||||
opportunities = [dict(row) for row in conn.execute(text("""
|
opportunities = [dict(row) for row in conn.execute(text("""
|
||||||
SELECT o.*, o.id::text AS id, o.local_customer_id::text,
|
SELECT o.*, o.id::text AS id, o.local_customer_id::text,
|
||||||
c.name AS linked_customer_name, c.tax_id, c.email AS fiscal_email,
|
c.name AS linked_customer_name, c.tax_id, c.email AS fiscal_email,
|
||||||
@@ -157,6 +171,7 @@ def _load(
|
|||||||
WHERE status IN ('open','needs_review','conflict') ORDER BY created_at
|
WHERE status IN ('open','needs_review','conflict') ORDER BY created_at
|
||||||
""")).mappings()]
|
""")).mappings()]
|
||||||
finally:
|
finally:
|
||||||
|
if transaction_started:
|
||||||
conn.execute(text("ROLLBACK"))
|
conn.execute(text("ROLLBACK"))
|
||||||
return {
|
return {
|
||||||
"identity": {"database": identity[0], "user": identity[1], "transaction_read_only": identity[2]},
|
"identity": {"database": identity[0], "user": identity[1], "transaction_read_only": identity[2]},
|
||||||
|
|||||||
125
tests/test_blif_flow_v2_readonly_transaction_order.py
Normal file
125
tests/test_blif_flow_v2_readonly_transaction_order.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import scripts.simulate_blif_flow_v2 as simulator
|
||||||
|
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def __init__(self, *, identity=None, rows=()):
|
||||||
|
self._identity = identity
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def one(self):
|
||||||
|
return self._identity
|
||||||
|
|
||||||
|
def mappings(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
class _Connection:
|
||||||
|
def __init__(self, database="clientflow", user="clientflow"):
|
||||||
|
self.database = database
|
||||||
|
self.user = user
|
||||||
|
self.read_only = False
|
||||||
|
self.statements = []
|
||||||
|
self.identity_observations = []
|
||||||
|
|
||||||
|
def execution_options(self, **_kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def execute(self, statement):
|
||||||
|
sql = " ".join(str(statement).split())
|
||||||
|
self.statements.append(sql)
|
||||||
|
upper = sql.upper()
|
||||||
|
if upper == "BEGIN READ ONLY":
|
||||||
|
self.read_only = True
|
||||||
|
return _Result()
|
||||||
|
if upper == "ROLLBACK":
|
||||||
|
self.read_only = False
|
||||||
|
return _Result()
|
||||||
|
if upper.startswith(("INSERT ", "UPDATE ", "DELETE ")):
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("cannot execute write in a read-only transaction")
|
||||||
|
return _Result()
|
||||||
|
if "CURRENT_DATABASE()" in upper:
|
||||||
|
identity = (self.database, self.user, "on" if self.read_only else "off")
|
||||||
|
self.identity_observations.append(identity)
|
||||||
|
return _Result(identity=identity)
|
||||||
|
return _Result(rows=())
|
||||||
|
|
||||||
|
|
||||||
|
class _Engine:
|
||||||
|
def __init__(self, connection):
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
return nullcontext(self.connection)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(monkeypatch, connection, **kwargs):
|
||||||
|
monkeypatch.setattr(simulator, "engine", _Engine(connection))
|
||||||
|
return simulator._load(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_load_establishes_read_only_before_same_connection_identity_and_reads(monkeypatch):
|
||||||
|
connection = _Connection()
|
||||||
|
assert connection.read_only is False # normal fresh-connection state
|
||||||
|
|
||||||
|
result = _load(
|
||||||
|
monkeypatch, connection, expected_database="clientflow",
|
||||||
|
expected_user="clientflow", require_read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert connection.statements[0] == "BEGIN READ ONLY"
|
||||||
|
assert "CURRENT_DATABASE()" in connection.statements[1].upper()
|
||||||
|
assert connection.identity_observations == [("clientflow", "clientflow", "on")]
|
||||||
|
assert result["identity"]["transaction_read_only"] == "on"
|
||||||
|
assert connection.statements[2].upper().startswith("SELECT O.*")
|
||||||
|
assert connection.statements[-1] == "ROLLBACK"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("database", "user", "expected_database", "expected_user"),
|
||||||
|
[
|
||||||
|
("wrong", "clientflow", "clientflow", "clientflow"),
|
||||||
|
("clientflow", "wrong", "clientflow", "clientflow"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_strict_load_refuses_wrong_identity_before_factual_reads(
|
||||||
|
monkeypatch, database, user, expected_database, expected_user,
|
||||||
|
):
|
||||||
|
connection = _Connection(database=database, user=user)
|
||||||
|
with pytest.raises(RuntimeError, match="unexpected database identity"):
|
||||||
|
_load(
|
||||||
|
monkeypatch, connection, expected_database=expected_database,
|
||||||
|
expected_user=expected_user, require_read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert connection.statements[0] == "BEGIN READ ONLY"
|
||||||
|
assert len([sql for sql in connection.statements if sql.upper().startswith("SELECT")]) == 1
|
||||||
|
assert connection.statements[-1] == "ROLLBACK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_factual_transaction_rejects_writes():
|
||||||
|
connection = _Connection()
|
||||||
|
connection.execute("BEGIN READ ONLY")
|
||||||
|
with pytest.raises(RuntimeError, match="read-only transaction"):
|
||||||
|
connection.execute("UPDATE opportunities SET stage = 'forbidden'")
|
||||||
|
assert connection.read_only is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_strict_load_preserves_identity_then_read_only_collection_order(monkeypatch):
|
||||||
|
connection = _Connection(database="clientflow_codex_test", user="clientflow_codex_test")
|
||||||
|
result = _load(
|
||||||
|
monkeypatch, connection, expected_database="clientflow_codex_test",
|
||||||
|
expected_user="clientflow_codex_test", require_read_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "CURRENT_DATABASE()" in connection.statements[0].upper()
|
||||||
|
assert connection.identity_observations == [
|
||||||
|
("clientflow_codex_test", "clientflow_codex_test", "off")
|
||||||
|
]
|
||||||
|
assert connection.statements[1] == "BEGIN READ ONLY"
|
||||||
|
assert result["identity"]["transaction_read_only"] == "off"
|
||||||
|
assert connection.statements[-1] == "ROLLBACK"
|
||||||
Reference in New Issue
Block a user