443 lines
20 KiB
Python
443 lines
20 KiB
Python
"""Сервис аудита (spec audit-trail, задача 21): системное событие, откат, append-only, статический запрет UPDATE/DELETE.
|
||
|
||
Быстрые тесты (валидация, сканер, статический обход `src/`) идут без БД; интеграционные — на `db_session`.
|
||
"""
|
||
|
||
import ast
|
||
import datetime
|
||
import inspect
|
||
import re
|
||
import uuid
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.models.audit_ex import AuditEvent
|
||
from src.models.directories import RegionalOffice
|
||
from src.models.enums import AuditAction, AuditSection
|
||
from src.services.audit import AuditService, to_jsonable
|
||
|
||
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
|
||
|
||
# ---------------------------------------------------------------------------------------------------------------------
|
||
# Статический сканер: обращения, изменяющие или удаляющие audit_event
|
||
# ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
MUTATING_CALLS = {"update", "delete"}
|
||
AUDIT_FIELDS = {
|
||
"occurred_at",
|
||
"user_id",
|
||
"action",
|
||
"section",
|
||
"object_type",
|
||
"object_id",
|
||
"consolidation_version_id",
|
||
"description",
|
||
"before_value",
|
||
"after_value",
|
||
"comment",
|
||
"result",
|
||
"changed_field",
|
||
"ip_address",
|
||
"user_agent",
|
||
"id",
|
||
}
|
||
RAW_SQL_MUTATION = re.compile(
|
||
r"\b(update|delete\s+from|truncate(\s+table)?)\s+(only\s+)?(\"?dkb\"?\.)?\"?audit_event\b", re.I
|
||
)
|
||
|
||
|
||
def _call_name(node: ast.Call) -> str | None:
|
||
func = node.func
|
||
if isinstance(func, ast.Name):
|
||
return func.id
|
||
if isinstance(func, ast.Attribute):
|
||
return func.attr
|
||
return None
|
||
|
||
|
||
def _audit_event_aliases(tree: ast.AST) -> set[str]:
|
||
"""Имена AuditEvent в модуле: сам `AuditEvent`, `from ... import AuditEvent as AE`, `AE2 = AE` (до фикспойнта)."""
|
||
aliases = {"AuditEvent"}
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.ImportFrom):
|
||
aliases.update(alias.asname or alias.name for alias in node.names if alias.name == "AuditEvent")
|
||
changed = True
|
||
while changed:
|
||
changed = False
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Assign) and _mentions(node.value, aliases) and not isinstance(node.value, ast.Call):
|
||
for target in node.targets:
|
||
if isinstance(target, ast.Name) and target.id not in aliases:
|
||
aliases.add(target.id)
|
||
changed = True
|
||
return aliases
|
||
|
||
|
||
def _mutating_names(tree: ast.AST) -> set[str]:
|
||
"""`update`/`delete` и их импортные псевдонимы (`from sqlalchemy import update as upd`)."""
|
||
names = set(MUTATING_CALLS)
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.ImportFrom):
|
||
names.update(alias.asname for alias in node.names if alias.name in MUTATING_CALLS and alias.asname)
|
||
return names
|
||
|
||
|
||
def _mentions(node: ast.AST, aliases: set[str]) -> bool:
|
||
return any(
|
||
(isinstance(sub, ast.Name) and sub.id in aliases) or (isinstance(sub, ast.Attribute) and sub.attr in aliases)
|
||
for sub in ast.walk(node)
|
||
)
|
||
|
||
|
||
def _is_audit_event_expr(node: ast.AST | None, aliases: set[str]) -> bool:
|
||
"""Выражение, дающее экземпляр AuditEvent: `AuditEvent(...)` / `mod.AuditEvent(...)` / `AE(...)`."""
|
||
return isinstance(node, ast.Call) and _mentions(node.func, aliases)
|
||
|
||
|
||
def _tainted_names(tree: ast.AST, aliases: set[str]) -> set[str]:
|
||
"""Имена, связанные с экземплярами AuditEvent: присваивание `AuditEvent(...)`, аннотация `: AuditEvent`."""
|
||
names: set[str] = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Assign) and _is_audit_event_expr(node.value, aliases):
|
||
names.update(t.id for t in node.targets if isinstance(t, ast.Name))
|
||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||
if _mentions(node.annotation, aliases) or _is_audit_event_expr(node.value, aliases):
|
||
names.add(node.target.id)
|
||
elif isinstance(node, ast.arg) and node.annotation is not None and _mentions(node.annotation, aliases):
|
||
names.add(node.arg)
|
||
return names
|
||
|
||
|
||
def find_audit_event_mutations(source: str, filename: str = "<src>") -> list[str]:
|
||
"""Находки вида `file:line: описание` — любое изменение/удаление audit_event в исходнике.
|
||
|
||
Ловит (best-effort, статически, в пределах одного модуля):
|
||
- `update(AuditEvent...)` / `delete(AuditEvent...)`, в т.ч. `sa.update`, `update as upd`, `AuditEvent.__table__`
|
||
как аргумент; псевдонимы `from ... import AuditEvent as AE` и `AE = AuditEvent`;
|
||
- вызов `.update()`/`.delete()`, где приёмник упоминает AuditEvent: `AuditEvent.__table__.update()`,
|
||
`session.query(AuditEvent).update(...)/.delete()`;
|
||
- `.delete(x)`/`delete(x)`, где x — экземпляр AuditEvent (имя связано с `AuditEvent(...)` или аннотацией);
|
||
- присваивание/удаление атрибутов таких экземпляров и `AuditEvent.<поле> = ...`;
|
||
- сырой SQL UPDATE/DELETE/TRUNCATE по audit_event в строковой константе.
|
||
|
||
НЕ ловит: объекты, загруженные из БД без аннотации (`obj = (await s.execute(select(...))).scalar_one()`),
|
||
`setattr`, `session.merge()`, SQL, собранный конкатенацией/f-строкой, межмодульный поток типов. Защита на уровне
|
||
БД (триггер/REVOKE UPDATE, DELETE) в Срез 0 не входит.
|
||
"""
|
||
tree = ast.parse(source, filename=filename)
|
||
aliases = _audit_event_aliases(tree)
|
||
tainted = _tainted_names(tree, aliases)
|
||
mutating = _mutating_names(tree)
|
||
findings: list[str] = []
|
||
|
||
def add(node: ast.AST, message: str) -> None:
|
||
findings.append(f"{filename}:{getattr(node, 'lineno', 0)}: {message}")
|
||
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Call):
|
||
name = _call_name(node)
|
||
args: list[ast.AST] = [*node.args, *(kw.value for kw in node.keywords)]
|
||
if name in mutating:
|
||
receiver = node.func.value if isinstance(node.func, ast.Attribute) else None
|
||
if any(_mentions(arg, aliases) for arg in args):
|
||
add(node, f"{name}() по AuditEvent")
|
||
elif receiver is not None and _mentions(receiver, aliases):
|
||
add(node, f".{name}() на выражении с AuditEvent")
|
||
elif any(isinstance(arg, ast.Name) and arg.id in tainted for arg in args):
|
||
add(node, f"{name}() экземпляра AuditEvent")
|
||
elif isinstance(node, ast.Assign | ast.AugAssign | ast.AnnAssign | ast.Delete):
|
||
targets = node.targets if isinstance(node, ast.Assign | ast.Delete) else [node.target]
|
||
for target in targets:
|
||
if isinstance(target, ast.Attribute) and target.attr in AUDIT_FIELDS:
|
||
base = target.value
|
||
if (
|
||
isinstance(base, ast.Name) and (base.id in tainted or base.id in aliases)
|
||
) or _is_audit_event_expr(base, aliases):
|
||
add(node, f"изменение поля {target.attr} у AuditEvent")
|
||
elif isinstance(node, ast.Constant) and isinstance(node.value, str) and RAW_SQL_MUTATION.search(node.value):
|
||
add(node, "сырой SQL, изменяющий audit_event")
|
||
return findings
|
||
|
||
|
||
def scan_directory(root: Path) -> list[str]:
|
||
findings: list[str] = []
|
||
for path in sorted(root.rglob("*.py")):
|
||
findings.extend(find_audit_event_mutations(path.read_text(encoding="utf-8"), str(path.relative_to(root))))
|
||
return findings
|
||
|
||
|
||
def test_src_has_no_audit_event_update_or_delete() -> None:
|
||
assert SRC_DIR.is_dir()
|
||
assert list(SRC_DIR.rglob("audit.py")), "сканер должен видеть src/services/audit.py"
|
||
assert scan_directory(SRC_DIR) == []
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"source",
|
||
[
|
||
"from sqlalchemy import update\nstmt = update(AuditEvent).values(comment='x')",
|
||
"import sqlalchemy as sa\nstmt = sa.delete(AuditEvent).where(AuditEvent.id == 1)",
|
||
"stmt = update(AuditEvent.__table__).values(result='x')",
|
||
"stmt = delete(models.audit_ex.AuditEvent)",
|
||
"async def f(session):\n event = AuditEvent(object_type='x')\n await session.delete(event)",
|
||
"def f(event: AuditEvent):\n event.result = 'changed'",
|
||
"def f(s):\n e = AuditEvent(object_type='x')\n e.comment = 'x'",
|
||
"def f(s):\n e = AuditEvent(object_type='x')\n del e.comment",
|
||
"AuditEvent.comment = 'x'",
|
||
"stmt = text('UPDATE dkb.audit_event SET comment = 1')",
|
||
"stmt = text('delete from audit_event')",
|
||
"stmt = text('TRUNCATE TABLE dkb.audit_event')",
|
||
# обходы через псевдонимы
|
||
"from src.models.audit_ex import AuditEvent as AE\nstmt = delete(AE)",
|
||
"from src.models.audit_ex import AuditEvent as AE\nstmt = sa.update(AE).values(result='x')",
|
||
"from src.models.audit_ex import AuditEvent as AE\nAE.comment = 'x'",
|
||
"from src.models.audit_ex import AuditEvent as AE\ndef f(s):\n e = AE(object_type='x')\n s.delete(e)",
|
||
"AE = AuditEvent\nstmt = update(AE)",
|
||
"from sqlalchemy import update as upd\nstmt = upd(AuditEvent)",
|
||
# __table__ как приёмник
|
||
"stmt = AuditEvent.__table__.update().values(result='x')",
|
||
"stmt = AuditEvent.__table__.delete()",
|
||
# устаревший Query API
|
||
"def f(s):\n s.query(AuditEvent).update({'result': 'x'})",
|
||
"def f(s):\n s.query(AuditEvent).filter(AuditEvent.id == 1).delete()",
|
||
"def f(s):\n from src.models.audit_ex import AuditEvent as AE\n s.query(AE).delete()",
|
||
],
|
||
)
|
||
def test_scanner_detects_violation(source: str) -> None:
|
||
assert find_audit_event_mutations(source), f"сканер пропустил нарушение: {source!r}"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"source",
|
||
[
|
||
"stmt = select(AuditEvent).where(AuditEvent.id == 1)",
|
||
"event = AuditEvent(object_type='x')\nsession.add(event)",
|
||
"stmt = update(RegionalOffice).values(name='x')",
|
||
"async def f(session, office):\n await session.delete(office)",
|
||
"def f(office):\n office.comment = 'x'",
|
||
"stmt = text('select * from audit_event')",
|
||
"from src.models.audit_ex import AuditEvent as AE\nstmt = select(AE).where(AE.id == 1)",
|
||
"stmt = select(AuditEvent.__table__)",
|
||
"def f(s):\n return s.query(AuditEvent).filter(AuditEvent.id == 1).all()",
|
||
"def f(s):\n s.query(RegionalOffice).update({'name': 'x'})\n s.query(RegionalOffice).delete()",
|
||
"async def f(session):\n await AuditService(session).record(action=A, section=S, object_type='x')",
|
||
"from src.services.audit import AuditService\nservice = AuditService(session)\nservice.update_cache()",
|
||
"def f(d):\n d.update({'a': 1})\n d.get('x').update({})",
|
||
],
|
||
)
|
||
def test_scanner_passes_allowed_code(source: str) -> None:
|
||
assert find_audit_event_mutations(source) == []
|
||
|
||
|
||
def test_scan_directory_fails_on_synthetic_violation(tmp_path: Path) -> None:
|
||
(tmp_path / "ok.py").write_text("x = 1\n", encoding="utf-8")
|
||
sub = tmp_path / "services"
|
||
sub.mkdir()
|
||
(sub / "bad.py").write_text("from sqlalchemy import update\nstmt = update(AuditEvent)\n", encoding="utf-8")
|
||
findings = scan_directory(tmp_path)
|
||
assert len(findings) == 1
|
||
assert findings[0].startswith("services/bad.py:2:")
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------------------------------
|
||
# Append-only и валидация (без БД)
|
||
# ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
|
||
def test_service_exposes_only_record() -> None:
|
||
public = {name for name in dir(AuditService) if not name.startswith("_")}
|
||
assert public == {"record"}
|
||
|
||
|
||
async def test_record_rejects_empty_object_type() -> None:
|
||
service = AuditService(AsyncMock(spec=AsyncSession))
|
||
for bad in ("", " "):
|
||
with pytest.raises(ValueError, match="object_type"):
|
||
await service.record(action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type=bad)
|
||
|
||
|
||
async def test_record_rejects_string_enums() -> None:
|
||
session = AsyncMock(spec=AsyncSession)
|
||
service = AuditService(session)
|
||
with pytest.raises(TypeError, match="action"):
|
||
await service.record(action="LOGIN", section=AuditSection.SESSION, object_type="app_user") # type: ignore[arg-type]
|
||
with pytest.raises(TypeError, match="section"):
|
||
await service.record(action=AuditAction.LOGIN, section="SESSION", object_type="app_user") # type: ignore[arg-type]
|
||
session.add.assert_not_called()
|
||
|
||
|
||
async def test_record_rejects_non_mapping_payload_and_unknown_types() -> None:
|
||
service = AuditService(AsyncMock(spec=AsyncSession))
|
||
with pytest.raises(TypeError, match="before_value"):
|
||
await service.record(
|
||
action=AuditAction.LOGIN,
|
||
section=AuditSection.SESSION,
|
||
object_type="app_user",
|
||
before_value=["x"], # type: ignore[arg-type]
|
||
)
|
||
with pytest.raises(TypeError, match="object"):
|
||
await service.record(
|
||
action=AuditAction.LOGIN,
|
||
section=AuditSection.SESSION,
|
||
object_type="app_user",
|
||
after_value={"x": object()},
|
||
)
|
||
|
||
|
||
def test_record_does_not_accept_occurred_at() -> None:
|
||
assert "occurred_at" not in inspect.signature(AuditService.record).parameters
|
||
|
||
|
||
def test_to_jsonable_types() -> None:
|
||
uid = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||
moment = datetime.datetime(2026, 10, 3, 12, 0, tzinfo=datetime.UTC)
|
||
result = to_jsonable(
|
||
{
|
||
"sum": Decimal("106311116.46"),
|
||
"tiny": Decimal("1E+2"),
|
||
"id": uid,
|
||
"at": moment,
|
||
"day": datetime.date(2026, 10, 3),
|
||
"action": AuditAction.LOGIN,
|
||
"nested": [{"k": Decimal("0.10")}, (1, 2)],
|
||
3: None,
|
||
}
|
||
)
|
||
assert result == {
|
||
"sum": "106311116.46",
|
||
"tiny": "100",
|
||
"id": str(uid),
|
||
"at": moment.isoformat(),
|
||
"day": "2026-10-03",
|
||
"action": "LOGIN",
|
||
"nested": [{"k": "0.10"}, [1, 2]],
|
||
"3": None,
|
||
}
|
||
with pytest.raises(ValueError, match="NaN"):
|
||
to_jsonable({"x": float("nan")})
|
||
|
||
|
||
# ---------------------------------------------------------------------------------------------------------------------
|
||
# Интеграция: запись в текущую транзакцию
|
||
# ---------------------------------------------------------------------------------------------------------------------
|
||
|
||
|
||
async def _count_events(session: AsyncSession, object_id: uuid.UUID) -> int:
|
||
return (
|
||
await session.scalar(select(func.count()).select_from(AuditEvent).where(AuditEvent.object_id == object_id)) or 0
|
||
)
|
||
|
||
|
||
async def _count_offices(session: AsyncSession, name: str) -> int:
|
||
return (
|
||
await session.scalar(select(func.count()).select_from(RegionalOffice).where(RegionalOffice.name == name)) or 0
|
||
)
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_system_event_without_user(db_session: AsyncSession) -> None:
|
||
# action/section выбраны произвольно: тест проверяет user_id=NULL и jsonb, а не предметный сценарий (по R-7
|
||
# обмен recognition_job audit_event в Срезе 0 не пишет)
|
||
before = datetime.datetime.now(datetime.UTC)
|
||
event = await AuditService(db_session).record(
|
||
action=AuditAction.RECOGNIZED_DATA_EDIT,
|
||
section=AuditSection.RECOGNITION,
|
||
object_type="recognition_job",
|
||
user_id=None,
|
||
before_value={"status": "queued"},
|
||
after_value={"status": "published"},
|
||
)
|
||
assert event.id is not None
|
||
await db_session.refresh(event) # перечитать строку из БД (jsonb round-trip)
|
||
|
||
row = (await db_session.execute(select(AuditEvent).where(AuditEvent.id == event.id))).scalar_one()
|
||
assert row.user_id is None
|
||
assert row.action is AuditAction.RECOGNIZED_DATA_EDIT
|
||
assert row.section is AuditSection.RECOGNITION
|
||
assert row.before_value == {"status": "queued"}
|
||
assert row.after_value == {"status": "published"}
|
||
assert row.occurred_at.tzinfo is not None
|
||
assert row.occurred_at.utcoffset() == datetime.timedelta(0)
|
||
assert (
|
||
before - datetime.timedelta(seconds=5)
|
||
<= row.occurred_at
|
||
<= datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=5)
|
||
)
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_payload_decimal_uuid_datetime_stored_as_strings(db_session: AsyncSession) -> None:
|
||
uid = uuid.uuid4()
|
||
moment = datetime.datetime(2026, 10, 3, 9, 30, 15, tzinfo=datetime.UTC)
|
||
event = await AuditService(db_session).record(
|
||
action=AuditAction.CORRECTION_CREATE,
|
||
section=AuditSection.CONSOLIDATION,
|
||
object_type="correction",
|
||
object_id=uid,
|
||
after_value={"amount": Decimal("106311116.46"), "ref": uid, "at": moment, "kind": AuditSection.KFO},
|
||
changed_field="amount",
|
||
comment="ручная корректировка",
|
||
result="ok",
|
||
ip_address="10.0.0.1",
|
||
user_agent="pytest",
|
||
description="тест",
|
||
)
|
||
await db_session.refresh(event)
|
||
assert event.after_value == {
|
||
"amount": "106311116.46",
|
||
"ref": str(uid),
|
||
"at": moment.isoformat(),
|
||
"kind": "KFO",
|
||
}
|
||
assert isinstance(event.after_value["amount"], str)
|
||
assert (event.object_id, event.changed_field, event.comment, event.result) == (
|
||
uid,
|
||
"amount",
|
||
"ручная корректировка",
|
||
"ok",
|
||
)
|
||
assert (event.ip_address, event.user_agent, event.description) == ("10.0.0.1", "pytest", "тест")
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_rollback_removes_audit_with_data(db_session: AsyncSession) -> None:
|
||
office_name = f"Филиал-аудит-{uuid.uuid4().hex[:8]}"
|
||
object_id = uuid.uuid4()
|
||
|
||
with pytest.raises(RuntimeError, match="отмена"):
|
||
async with db_session.begin_nested(): # SAVEPOINT: откат при исключении, как откат транзакции запроса
|
||
db_session.add(RegionalOffice(name=office_name))
|
||
event = await AuditService(db_session).record(
|
||
action=AuditAction.DIRECTORY_IMPORT,
|
||
section=AuditSection.DIRECTORIES,
|
||
object_type="regional_office",
|
||
object_id=object_id,
|
||
)
|
||
# внутри транзакции запись и данные видны
|
||
assert event.occurred_at is not None
|
||
seen = await _count_events(db_session, object_id)
|
||
assert seen == 1
|
||
assert await _count_offices(db_session, office_name) == 1
|
||
raise RuntimeError("отмена")
|
||
|
||
assert await _count_events(db_session, object_id) == 0
|
||
assert await _count_offices(db_session, office_name) == 0
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_record_does_not_commit_and_external_rollback_discards(db_session: AsyncSession) -> None:
|
||
object_id = uuid.uuid4()
|
||
await AuditService(db_session).record(
|
||
action=AuditAction.LOGIN, section=AuditSection.SESSION, object_type="app_user", object_id=object_id
|
||
)
|
||
assert db_session.in_transaction() # record не закрыл транзакцию (нет commit)
|
||
assert await _count_events(db_session, object_id) == 1
|
||
|
||
await db_session.rollback() # внешний откат (в фикстуре — к SAVEPOINT) уничтожает запись
|
||
assert await _count_events(db_session, object_id) == 0
|