Audit trail¶
AuditMixin guarda quem mexeu por último (created_by / updated_by) e o BaseModel guarda quando (created_at / updated_at). Nenhum dos dois guarda o histórico das mudanças. O audit trail adiciona um log append-only: uma linha por create / update / delete, com o ator, a ação e um diff antes/depois das colunas alteradas.
A linha de auditoria é gravada na mesma transação da mudança — add_audited / update_audited adicionam a linha de auditoria e a linha de negócio e commitam as duas juntas, o mesmo padrão que o outbox usa —, então uma entrada de auditoria nunca referencia uma mudança que foi revertida.
A tabela de auditoria¶
Subclasse BaseAuditLogModel e escolha um __tablename__ (audit_log por convenção), igual ao BaseOutboxModel:
from tempest_fastapi_sdk import BaseAuditLogModel
class AuditLogModel(BaseAuditLogModel):
"""Log append-only de mutações por entidade."""
__tablename__ = "audit_log"
Herda os quatro campos canônicos (id, is_active, created_at, updated_at) mais: entity (nome do model), entity_id (id da linha, como texto), action (AuditAction), actor (quem fez, ou None), changes (o diff em JSON) e context (metadados opcionais — request id, motivo). Evento de domínio, IP, user agent e autor com FK ganham colunas próprias em Evento, origem da requisição e autor.
Ligando no repository¶
Passe audit_model= no repository e use as variantes auditadas. Elas gravam a linha de negócio e a de auditoria juntas:
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from src.db.models import AuditLogModel, ProductModel
class ProductRepository(BaseRepository[ProductModel]):
"""Repository de produtos com trilha de auditoria."""
def __init__(self, session: AsyncSession) -> None:
"""Inicializa o repository.
Args:
session (AsyncSession): A sessão async do banco.
"""
super().__init__(session, model=ProductModel, audit_model=AuditLogModel)
Create¶
import asyncio
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from src.db.models import ProductModel
from src.db.repositories import ProductRepository
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
repo = ProductRepository(session)
# O ator é o id de quem já está autenticado (ex.: `current_user.id`).
actor_id = UUID("2b1d0c2e-7f3a-4c56-9d18-2f9a4c5b6d70")
async def main() -> None:
"""Run this example."""
product = await repo.add_audited(ProductModel(name="Widget"), actor=str(actor_id))
# grava o produto + uma entrada CREATE com {"after": {...}}
asyncio.run(main())
Update — tire um snapshot antes de mutar¶
update_audited precisa do estado anterior para calcular o diff. Tire o snapshot com repo.snapshot(...) antes de alterar a instância:
from uuid import UUID
from src.db.repositories import ProductRepository
async def rename_product(
repo: ProductRepository, product_id: UUID, name: str, actor: str
) -> None:
"""Renomeia um produto registrando o diff na auditoria.
Args:
repo (ProductRepository): O repository de produtos.
product_id (UUID): O id do produto.
name (str): O novo nome.
actor (str): Quem realizou a alteração.
Raises:
NotFoundException: Se o produto não existe.
"""
product = await repo.get_by_id(product_id)
before = repo.snapshot(product) # ← antes de mutar
product.name = name
await repo.update_audited(product, before, actor=actor)
# grava uma entrada UPDATE com {"name": {"before": "...", "after": "..."}}
Delete¶
import asyncio
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from src.db.repositories import ProductRepository
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
repo = ProductRepository(session)
product_id = UUID("6f1c3d84-2a55-4d0b-9d7e-0c1a2b3c4d5e")
actor_id = UUID("2b1d0c2e-7f3a-4c56-9d18-2f9a4c5b6d70")
async def main() -> None:
"""Run this example."""
product = await repo.get_by_id(product_id)
await repo.delete_audited(product, actor=str(actor_id))
# apaga a linha + grava uma entrada DELETE com {"before": {...}}
asyncio.run(main())
Mesma transação
As três variantes gravam a linha de negócio e a de auditoria juntas. Chamadas soltas, elas commitam as duas no fim; dentro de um bloco repo.transaction() (ou num repository com autocommit=False) elas só fazem flush, e o commit é do bloco — se o bloco aborta, as duas linhas somem juntas. Nos dois casos, se a auditoria falhar a mudança é revertida — nunca fica meia gravada. Veja Transações. Repositories sem audit_model levantam RuntimeError ao chamar os métodos auditados.
Evento, origem da requisição e autor¶
Só com actor e context, "tudo o que o usuário X fez" vira varredura de JSON, e o IP e o user agent ficam em chaves que cada serviço nomeia de um jeito. Três peças tiram isso do context:
AuditRequestMixin— colunasevent(indexada),ipeuser_agentna tabela de auditoria.AuditRequestContext.from_request(request, trusted_ip_header=...)— lê o IP do cliente (peloget_client_ip) e oUser-Agent.record_event(...)— grava um evento de domínio que não muda linha nenhuma (action="event").
E um ponto de extensão: declare actor_id com FK na sua subclasse e passe actor_id= — sem sobrescrever new_entry.
import asyncio
from collections.abc import AsyncGenerator
from uuid import UUID
import httpx
from fastapi import Depends, FastAPI, Request
from sqlalchemy import ForeignKey, String, delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AuditRequestContext,
AuditRequestMixin,
BaseAuditLogModel,
BaseModel,
BaseRepository,
)
class UserModel(BaseModel):
"""Conta de usuário."""
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), nullable=False)
class AuditLogModel(AuditRequestMixin, BaseAuditLogModel):
"""Auditoria com evento, origem e autor ligado ao usuário."""
__tablename__ = "audit_log"
actor_id: Mapped[UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
class ConsentModel(BaseModel):
"""Consentimento dado por um usuário."""
__tablename__ = "consents"
user_id: Mapped[UUID] = mapped_column(nullable=False)
purpose: Mapped[str] = mapped_column(String(64), nullable=False)
class ConsentRepository(BaseRepository[ConsentModel]):
"""Repository de consentimentos com trilha de auditoria."""
def __init__(self, session: AsyncSession) -> None:
"""Inicializa o repository.
Args:
session (AsyncSession): A sessão async do banco.
"""
super().__init__(session, model=ConsentModel, audit_model=AuditLogModel)
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
app = FastAPI()
async def get_session() -> AsyncGenerator[AsyncSession]:
"""Entrega uma sessão por requisição.
Yields:
AsyncSession: A sessão aberta.
"""
async with db.get_session_context() as session:
yield session
@app.post("/users/{user_id}/consents")
async def grant_consent(
user_id: UUID,
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
"""Registra o consentimento e o pedido de exportação do usuário.
Args:
user_id (UUID): O usuário que consente.
request (Request): A requisição, de onde saem IP e user agent.
session (AsyncSession): A sessão do banco.
Returns:
dict[str, str]: O id do consentimento criado.
"""
origin = AuditRequestContext.from_request(request, trusted_ip_header="x-real-ip")
repo = ConsentRepository(session)
consent = await repo.add_audited(
ConsentModel(user_id=user_id, purpose="marketing"),
actor=str(user_id),
actor_id=user_id,
event="consent.granted",
request_context=origin,
)
await repo.record_event(
"export.requested",
actor=str(user_id),
actor_id=user_id,
request_context=origin,
)
return {"id": str(consent.id)}
async def main() -> None:
"""Run this example."""
await db.connect()
await db.create_tables()
async with db.get_session_context() as session:
user = UserModel(email="ana@example.com")
session.add(user)
await session.commit()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://api") as client:
await client.post(
f"/users/{user.id}/consents",
headers={"X-Real-IP": "203.0.113.7", "User-Agent": "app/2.1"},
)
async with db.get_session_context() as session:
await session.execute(delete(UserModel))
await session.commit()
rows = (await session.execute(select(AuditLogModel))).scalars().all()
for row in sorted(rows, key=lambda r: r.action):
print(row.action, row.event, row.entity_id != "", row.ip, row.user_agent, row.actor_id)
await db.disconnect()
asyncio.run(main())
Saída:
create consent.granted True 203.0.113.7 app/2.1 None
event export.requested False 203.0.113.7 app/2.1 None
Pedaço por pedaço:
AuditRequestMixinentra antes deBaseAuditLogModele acrescenta as três colunas.eventé indexada: "todo consentimento concedido" é uma busca por índice, não um parse de JSON.actor_idé seu: a FK aponta para a sua tabela de usuários, e o SDK não precisa saber qual é. OON DELETE SET NULLé o que deixa oactor_idemNonena saída — a conta foi apagada, a trilha ficou, sem o vínculo com o titular.from_requestexigetrusted_ip_headersem default: confiar num header de proxy é um fato do seu deploy, e o SDK não adivinha. Passe o único header que a borda sobrescreve ("x-real-ip"), ouNonepara usar o peer da conexão. Valor que não é endereço IP viraNone, eUser-Agentacima de 512 caracteres é cortado — o header é do cliente, e um gigante não pode derrubar a escrita de negócio.record_eventgravaaction="event", comentityigual ao model do repository eentity_idvazio (ou o id dosubject=que você passar), echangesigual a{}se você não mandar payload.request_context=eip=/user_agent=são alternativas: passar os dois levantaValueError.
Tabela que já existe
As colunas vêm de um mixin, não do BaseAuditLogModel, de propósito: quem já tem audit_log criado continua funcionando sem migration, chamando só com actor / context. Acrescentar AuditRequestMixin (ou actor_id) a uma tabela existente é mudança de schema — gere a migration do Alembic antes de subir.
Valor para coluna que a tabela não tem é recusado
Passar event=, ip=, user_agent= ou actor_id= para uma tabela sem a coluna levanta ValueError antes do commit, e a transação inteira é revertida — inclusive a linha de negócio:
AuditLogModel has no column for: event, ip. Mix AuditRequestMixin in for event/ip/user_agent, or declare an actor_id column for actor_id.
Perder um fato de auditoria em silêncio seria pior do que falhar a chamada. Pelo mesmo motivo, record_event exige o mixin: o nome do evento é o que a linha registra.
Outras colunas de domínio — columns=¶
O actor_id não é a única coluna que vale ter na trilha: o titular sobre quem
o evento fala, o tenant, a organização. Declare a coluna na subclasse e passe
o valor em columns= — add_audited, update_audited, delete_audited,
record_event, os for_* e o new_entry aceitam, sem override:
import asyncio
from typing import Any
from uuid import UUID
from sqlalchemy import ForeignKey, String, delete, select
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseAuditLogModel,
BaseModel,
BaseRepository,
)
class TeenModel(BaseModel):
"""The data subject an event is about."""
__tablename__ = "teens"
name: Mapped[str] = mapped_column(String(64))
class InviteModel(BaseModel):
"""The audited business row."""
__tablename__ = "invites"
code: Mapped[str] = mapped_column(String(16))
class AuditLogModel(BaseAuditLogModel):
"""Audit table with a column for the subject of each entry."""
__tablename__ = "audit_log"
subject_teen_id: Mapped[UUID | None] = mapped_column(
ForeignKey("teens.id", ondelete="SET NULL"), nullable=True, index=True
)
class InviteRepository(BaseRepository[InviteModel]):
"""Invites, audited into ``AuditLogModel``."""
def __init__(self, session: Any) -> None:
"""Bind the session and the audit model.
Args:
session (Any): The request session.
"""
super().__init__(session, model=InviteModel, audit_model=AuditLogModel)
async def main() -> None:
"""Audit an invite about a teen, then erase the teen."""
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.connect()
await db.create_tables()
async with db.get_session_context() as session:
teen = TeenModel(name="Ana")
session.add(teen)
await session.commit()
await InviteRepository(session).add_audited(
InviteModel(code="ABC123"),
actor="guardian",
columns={"subject_teen_id": teen.id},
)
row = (await session.execute(select(AuditLogModel))).scalar_one()
print(row.action, row.subject_teen_id == teen.id)
await session.execute(delete(TeenModel))
await session.commit()
session.expire_all()
row = (await session.execute(select(AuditLogModel))).scalar_one()
print(row.action, row.subject_teen_id)
await db.disconnect()
asyncio.run(main())
A FK com SET NULL faz o vínculo sumir quando o titular é apagado, e a
entrada fica. Uma chave sem coluna na tabela levanta ValueError e reverte a
transação inteira, inclusive a linha de negócio; uma chave que já tem
parâmetro próprio (actor_id, event, entity, changes... — o conjunto
está em AUDIT_RESERVED_COLUMNS) também é recusada.
Helpers avulsos¶
Fora do repository, snapshot_model(instance) e diff_snapshots(before, after) ficam disponíveis, e BaseAuditLogModel.for_create / for_update / for_delete constroem a entrada (sem adicionar à sessão) quando você quer controlar a gravação manualmente.
Recapitulando¶
BaseAuditLogModel(subclasse com__tablename__) +AuditAction.repo = Repository(session, model=..., audit_model=AuditLogModel).add_audited/update_audited(model, before)/delete_audited— negócio + auditoria na mesma tx.repo.snapshot(model)antes de mutar;snapshot_model/diff_snapshotspara uso manual.AuditRequestMixin(opt-in) +event=/ip=/user_agent=/request_context=AuditRequestContext.from_request(...);actor_id=para a FK que você declarar;record_event(...)para evento sem mutação.