144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
"""PostgreSQL integration coverage for append-only audit events."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import cast
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import DBAPIError
|
|
|
|
from ragcore.application.audit import AuditEventService
|
|
from ragcore.config import Settings
|
|
from ragcore.domain.audit import AuditEvent, AuditEventType
|
|
from ragcore.domain.control import Workspace
|
|
from ragcore.infrastructure.database import (
|
|
create_database_engine,
|
|
create_session_factory,
|
|
session_scope,
|
|
)
|
|
from ragcore.infrastructure.database.audit import AuditEventRepository
|
|
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def audit_context() -> tuple[AuditEventRepository, Workspace, Workspace]:
|
|
engine = create_database_engine(Settings(env="test"))
|
|
factory = create_session_factory(engine)
|
|
async with session_scope(factory) as session:
|
|
suffix = uuid4().hex[:8]
|
|
first = Workspace(uuid4(), "First", f"first-{suffix}")
|
|
second = Workspace(uuid4(), "Second", f"second-{suffix}")
|
|
control = ControlPlaneRepository(session)
|
|
await control.create_workspace(first)
|
|
await control.create_workspace(second)
|
|
await session.commit()
|
|
yield AuditEventRepository(session), first, second
|
|
await session.rollback()
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_audit_events_redact_content_and_preserve_trace_correlation(
|
|
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
|
) -> None:
|
|
repository, workspace, _ = audit_context
|
|
service = AuditEventService(repository, clock=lambda: datetime(2026, 8, 1, tzinfo=UTC))
|
|
event = await service.record(
|
|
workspace_id=workspace.id,
|
|
event_type=AuditEventType.SECURITY_FAILURE,
|
|
trace_id="trace-123",
|
|
correlation_id="request-456",
|
|
details={
|
|
"reason": "denied",
|
|
"token": "rc_sa_secret",
|
|
"query": "private text",
|
|
"nested": {"password": "secret", "safe": "kept"},
|
|
},
|
|
)
|
|
await repository._session.commit()
|
|
|
|
stored = await service.list_events(workspace.id)
|
|
assert event.trace_id == "trace-123"
|
|
assert stored[0].correlation_id == "request-456"
|
|
assert stored[0].details == {"reason": "denied", "nested": {"safe": "kept"}}
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_repository_resanitizes_mutated_event_details(
|
|
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
|
) -> None:
|
|
repository, workspace, _ = audit_context
|
|
event = AuditEvent(
|
|
id=uuid4(),
|
|
workspace_id=workspace.id,
|
|
event_type=AuditEventType.ADMIN_ACTION,
|
|
occurred_at=datetime.now(UTC),
|
|
trace_id="mutable-details",
|
|
details={"nested": {}},
|
|
)
|
|
nested = cast(dict[str, object], event.details["nested"])
|
|
nested["token"] = "must-not-persist"
|
|
|
|
await repository.append(event)
|
|
await repository._session.commit()
|
|
|
|
stored = await repository.list_events(workspace.id)
|
|
assert stored[0].details == {"nested": {}}
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_audit_reads_are_workspace_scoped(
|
|
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
|
) -> None:
|
|
repository, first, second = audit_context
|
|
service = AuditEventService(repository)
|
|
await service.record(
|
|
workspace_id=first.id,
|
|
event_type=AuditEventType.ADMIN_ACTION,
|
|
trace_id="first-trace",
|
|
)
|
|
await service.record(
|
|
workspace_id=second.id,
|
|
event_type=AuditEventType.ADMIN_ACTION,
|
|
trace_id="second-trace",
|
|
)
|
|
await repository._session.commit()
|
|
|
|
assert [event.trace_id for event in await service.list_events(first.id)] == ["first-trace"]
|
|
assert [event.trace_id for event in await service.list_events(second.id)] == ["second-trace"]
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_database_rejects_audit_update_delete_and_truncate(
|
|
audit_context: tuple[AuditEventRepository, Workspace, Workspace],
|
|
) -> None:
|
|
repository, workspace, _ = audit_context
|
|
service = AuditEventService(repository)
|
|
event = await service.record(
|
|
workspace_id=workspace.id,
|
|
event_type=AuditEventType.ADMIN_ACTION,
|
|
trace_id="immutable-trace",
|
|
)
|
|
await repository._session.commit()
|
|
|
|
with pytest.raises(DBAPIError, match="append-only"):
|
|
await repository._session.execute(
|
|
text("UPDATE audit.audit_events SET trace_id = 'changed' WHERE id = :id"),
|
|
{"id": event.id},
|
|
)
|
|
await repository._session.rollback()
|
|
with pytest.raises(DBAPIError, match="append-only"):
|
|
await repository._session.execute(
|
|
text("DELETE FROM audit.audit_events WHERE id = :id"), {"id": event.id}
|
|
)
|
|
await repository._session.rollback()
|
|
with pytest.raises(DBAPIError, match="append-only"):
|
|
await repository._session.execute(text("TRUNCATE audit.audit_events"))
|
|
await repository._session.rollback()
|
|
|
|
assert (await service.list_events(workspace.id))[0].trace_id == "immutable-trace"
|