Audit trail¶
AuditMixin records who last touched a row (created_by /
updated_by) and BaseModel records when (created_at /
updated_at). Neither keeps the history of changes. The audit trail
adds an append-only log: one row per create / update / delete, with the
actor, the action and a before/after diff of the changed columns.
The audit row is written in the same transaction as the change —
add_audited / update_audited add the audit row and the business row
and commit them together, the same pattern the outbox uses — so an audit
entry can never reference a change that was rolled back.
The audit table¶
Subclass BaseAuditLogModel and pick a __tablename__ (audit_log by
convention), like BaseOutboxModel:
from tempest_fastapi_sdk import BaseAuditLogModel
class AuditLogModel(BaseAuditLogModel):
"""Append-only per-entity mutation log."""
__tablename__ = "audit_log"
It inherits the four canonical columns (id, is_active, created_at,
updated_at) plus: entity (model name), entity_id (row id, as
text), action (AuditAction), actor (who did it, or None),
changes (the JSON diff) and context (optional metadata — request id,
reason). Domain event, IP, user agent and an FK author get their own
columns in Event, request origin and author.
Wiring it into the repository¶
Pass audit_model= to the repository and use the audited variants. They
write the business row and the audit row together:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from src.db.models import AuditLogModel, ProductModel
class ProductRepository(BaseRepository[ProductModel]):
"""Product repository with an audit trail."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the repository.
Args:
session (AsyncSession): The async database session.
"""
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
# In a service the session comes from `db.get_session_context()`; here, SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
repo = ProductRepository(session)
# The actor is the id of whoever is already authenticated (`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))
# writes the product + a CREATE entry with {"after": {...}}
asyncio.run(main())
Update — snapshot before mutating¶
update_audited needs the previous state to compute the diff. Take
the snapshot with repo.snapshot(...) before mutating the instance:
from uuid import UUID
from src.db.repositories import ProductRepository
async def rename_product(
repo: ProductRepository, product_id: UUID, name: str, actor: str
) -> None:
"""Rename a product, recording the diff in the audit trail.
Args:
repo (ProductRepository): The product repository.
product_id (UUID): The product id.
name (str): The new name.
actor (str): Who performed the change.
Raises:
NotFoundException: If the product does not exist.
"""
product = await repo.get_by_id(product_id)
before = repo.snapshot(product) # ← before mutating
product.name = name
await repo.update_audited(product, before, actor=actor)
# writes an UPDATE entry with {"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
# In a service the session comes from `db.get_session_context()`; here, 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))
# deletes the row + writes a DELETE entry with {"before": {...}}
asyncio.run(main())
Same transaction
All three variants write the business row and the audit row
together. Called on their own, they commit both at the end; inside
a repo.transaction() block (or on a repository built with
autocommit=False) they only flush, and the commit belongs to the
block — if the block aborts, both rows disappear together. Either way,
if the audit write fails the change is rolled back — never
half-written. See Transactions. Repositories without
audit_model raise RuntimeError when the audited methods are called.
Event, request origin and author¶
With only actor and context, "everything user X did" is a JSON scan,
and the IP and user agent live under keys each service names its own way.
Three pieces move them out of context:
AuditRequestMixin—event(indexed),ipanduser_agentcolumns on the audit table.AuditRequestContext.from_request(request, trusted_ip_header=...)— reads the client IP (throughget_client_ip) and theUser-Agent.record_event(...)— writes a domain event that changes no row (action="event").
Plus one extension point: declare actor_id with an FK on your subclass
and pass actor_id= — no new_entry override.
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):
"""User account."""
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), nullable=False)
class AuditLogModel(AuditRequestMixin, BaseAuditLogModel):
"""Audit log with event, origin and an author linked to the user."""
__tablename__ = "audit_log"
actor_id: Mapped[UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
class ConsentModel(BaseModel):
"""Consent given by a user."""
__tablename__ = "consents"
user_id: Mapped[UUID] = mapped_column(nullable=False)
purpose: Mapped[str] = mapped_column(String(64), nullable=False)
class ConsentRepository(BaseRepository[ConsentModel]):
"""Consent repository with an audit trail."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the repository.
Args:
session (AsyncSession): The async database session.
"""
super().__init__(session, model=ConsentModel, audit_model=AuditLogModel)
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
app = FastAPI()
async def get_session() -> AsyncGenerator[AsyncSession]:
"""Provide one session per request.
Yields:
AsyncSession: The open session.
"""
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]:
"""Record the user's consent and their export request.
Args:
user_id (UUID): The consenting user.
request (Request): The request the IP and user agent come from.
session (AsyncSession): The database session.
Returns:
dict[str, str]: The id of the created consent.
"""
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())
Output:
create consent.granted True 203.0.113.7 app/2.1 None
event export.requested False 203.0.113.7 app/2.1 None
Piece by piece:
AuditRequestMixingoes beforeBaseAuditLogModeland adds the three columns.eventis indexed: "every consent granted" is an index lookup, not a JSON parse.actor_idis yours: the FK points at your users table, and the SDK does not need to know which.ON DELETE SET NULLis what leavesactor_idasNonein the output — the account was erased, the trail stayed, without the link to the data subject.from_requestrequirestrusted_ip_header, with no default: trusting a proxy header is a fact of your deployment, and the SDK does not guess. Pass the single header your edge overwrites ("x-real-ip"), orNoneto use the connection peer. A value that is not an IP address becomesNone, and aUser-Agentover 512 characters is truncated — the header belongs to the client, and a huge one must not break the business write.record_eventwritesaction="event", withentityset to the repository's model and an emptyentity_id(or the id of thesubject=you pass), andchangesset to{}when you send no payload.request_context=andip=/user_agent=are alternatives: passing both raisesValueError.
A table that already exists
The columns come from a mixin, not from BaseAuditLogModel, on
purpose: whoever already has audit_log created keeps working with
no migration, calling with actor / context only. Adding
AuditRequestMixin (or actor_id) to an existing table is a schema
change — generate the Alembic migration before deploying.
A value for a column the table lacks is refused
Passing event=, ip=, user_agent= or actor_id= to a table
without that column raises ValueError before the commit, and the
whole transaction is rolled back — the business row included:
AuditLogModel has no column for: event, ip. Mix AuditRequestMixin in for event/ip/user_agent, or declare an actor_id column for actor_id.
Silently losing an audit fact would be worse than failing the call.
For the same reason, record_event requires the mixin: the event name
is what the row records.
Other domain columns — columns=¶
actor_id is not the only column worth having in the trail: the subject the
event is about, the tenant, the organization. Declare the column on the
subclass and pass the value in columns= — add_audited, update_audited,
delete_audited, record_event, the for_* builders and new_entry all
take it, with no 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())
The SET NULL FK drops the link when the subject is erased, and the entry
stays. A key with no column on the table raises ValueError and rolls back
the whole transaction, business row included; a key that already has a
parameter of its own (actor_id, event, entity, changes... — the set
is AUDIT_RESERVED_COLUMNS) is refused too.
Standalone helpers¶
Outside the repository, snapshot_model(instance) and
diff_snapshots(before, after) are available, and
BaseAuditLogModel.for_create / for_update / for_delete build the entry
(without adding it to the session) when you want to control the write
yourself.
Recap¶
BaseAuditLogModel(subclass with__tablename__) +AuditAction.repo = Repository(session, model=..., audit_model=AuditLogModel).add_audited/update_audited(model, before)/delete_audited— business + audit in the same tx.repo.snapshot(model)before mutating;snapshot_model/diff_snapshotsfor manual use.AuditRequestMixin(opt-in) +event=/ip=/user_agent=/request_context=AuditRequestContext.from_request(...);actor_id=for the FK you declare;record_event(...)for an event with no mutation.