Audit Trail¶
The audit trail system links request-time authentication (AuthContext) to persistent database records, enabling you to answer "who changed what and when" from both the database and logs.
Overview¶
flowchart LR
subgraph "Request"
CAC[current_auth_context]
end
subgraph "Database"
TAC[TransactionAuthContext]
TXN[Transaction]
ACT[Activity]
ATP[*ActorTrackableMixin]
end
subgraph "Logs"
LOG[authnz field]
end
CAC -->|"id (UUID)"| TAC
CAC -->|"build_authnz_info()"| LOG
TAC -->|"FK"| TXN
TXN -->|"FK"| ACT
ATP -->|"FK"| TAC
LOG -.->|"join on id"| TAC
Key concepts:
current_auth_context.id- UUID generated per request, used as linking keyTransactionAuthContext- Database table storing auth context snapshotTransaction/Activity- Versioning tables that referenceTransactionAuthContextauthnzlog field - Structured log field containing auth context, same UUID
TransactionAuthContext¶
What It Is¶
TransactionAuthContext is an abstract base model that stores authentication context for database transactions. Each app creates its own concrete subclass with columns tailored to its principal types.
from shared.models.versioning.base import TransactionAuthContextBase
class TransactionAuthContextBase(AlanNonVersionedModel):
__tablename__ = "transaction_auth_context"
__abstract__ = True
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=_get_auth_context_id, # Captures current_auth_context.id
)
Key characteristics:
- Abstract base - Each app must create a concrete subclass
- UUID primary key - Always matches
current_auth_context.id - Default values - Column defaults capture auth context at insert time
Per-App Configuration (Polymorphism)¶
Each app defines which principal types to track. Example from EU Tools:
from shared.models.versioning.base import TransactionAuthContextBase
class EuToolsTransactionAuthContext(TransactionAuthContextBase):
# Track different principal types
real_alaner_id: Mapped[int | None] = mapped_column(
Integer, nullable=True, index=True,
default=partial(default_real_principal_id, Alaner),
)
real_alaner: Mapped[Alaner | None] = relationship(Alaner, ...)
real_external_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True, index=True,
default=partial(default_real_principal_id, ExternalUser),
)
real_external_user: Mapped[ExternalUser | None] = relationship(ExternalUser, ...)
real_service_account_id: Mapped[uuid.UUID | None] = mapped_column(...)
real_service_account: Mapped[ServiceAccount | None] = relationship(...)
@property
def actor(self) -> Alaner | ExternalUser | ServiceAccount | None:
return self.real_alaner or self.real_external_user or self.real_service_account
Linking to Transaction¶
The Transaction class references TransactionAuthContext via the transaction_auth_context_cls attribute:
class EuToolsTransaction(TransactionBase):
transaction_auth_context_cls = EuToolsTransactionAuthContext
auth_context_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey(EuToolsTransactionAuthContext.id),
index=True,
)
auth_context: Mapped[EuToolsTransactionAuthContext] = relationship(...)
Relationships¶
flowchart TB
subgraph "Request Time"
CAC["current_auth_context<br/>(LocalProxy)"]
AC["AuthContext<br/>(frozen dataclass)"]
end
subgraph "Database Tables"
TAC["TransactionAuthContext<br/>id, real_*_id columns"]
TXN["Transaction<br/>id, auth_context_id, issued_at"]
ACT["Activity<br/>id, transaction_id, verb, old_data, changed_data"]
end
subgraph "Model Mixin"
ATP["EuToolsActorTrackableMixin<br/>auth_context_id, auth_context"]
end
CAC --> AC
AC -->|"id (UUID)"| TAC
TAC -->|"FK auth_context_id"| TXN
TXN -->|"FK transaction_id"| ACT
ATP -->|"auth_context_id"| TAC
| Relationship | Description |
|---|---|
Transaction.auth_context_id → TransactionAuthContext.id |
Links transaction to auth context |
Activity.transaction_id → Transaction.id |
Links row changes to transaction |
*ActorTrackableMixin.auth_context_id → TransactionAuthContext.id |
Per-app mixin for models needing actor tracking |
ActorTrackable Mixin¶
Each app has its own ActorTrackableMixin created by the VersioningManager. The mixin is dynamically configured with auth_context_id and auth_context columns when auth_context_cls is set:
# In eu_tools_versioning_manager.py
eu_tools_versioning_manager = VersioningManager(
transaction_cls=EuToolsTransaction,
activity_cls=EuToolsActivity,
...
)
# Export the per-app mixin
EuToolsActorTrackableMixin: type = eu_tools_versioning_manager.actor_trackable_mixin
Models that need actor tracking inherit from the app-specific mixin:
from apps.eu_tools.alan_home.models.eu_tools_versioning_manager import EuToolsActorTrackableMixin
class MyModel(EuToolsActorTrackableMixin, BaseModel):
...
# Automatically gets:
# - auth_context_id: FK to TransactionAuthContext
# - auth_context: relationship to TransactionAuthContext
Lifecycle¶
sequenceDiagram
participant R as Request
participant AC as AuthContext
participant P as Auth Provider
participant DB as Database
participant TAC as TransactionAuthContext
participant TXN as Transaction
R->>AC: Request starts (anonymous context)
P->>AC: set_auth_context(principal)
Note over AC: id = UUID generated
R->>DB: First flush with changes
DB->>TAC: INSERT (ON CONFLICT DO NOTHING)
Note over TAC: Captures current_auth_context values
DB->>TXN: INSERT with auth_context_id
Note over DB: PostgreSQL triggers create Activity rows
R->>R: Request ends
Note over AC: Context destroyed (Flask g scope)
- Request starts → Anonymous
AuthContextcreated with new UUID - Auth provider authenticates →
set_auth_context()stores principal info - First DB flush with changes:
TransactionAuthContextinserted usingON CONFLICT DO NOTHING(idempotent)Transactioninserted withauth_context_id- PostgreSQL audit triggers create
Activityrows - Request ends → Context destroyed (Flask
gscope cleanup)
Idempotency
Multiple flushes in the same request reuse the same TransactionAuthContext row thanks to ON CONFLICT DO NOTHING.
Structured Logging (authnz field)¶
What It Is¶
The authnz field is automatically added to structured logs when LOG_ENRICHMENT is enabled. It captures a snapshot of current_auth_context at log time.
Source: shared/helpers/logging/configure_logging.py → build_authnz_info()
Structure¶
{
"authnz": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"authenticated": true,
"real_principal": {
"type": "back_office",
"class": "Alaner",
"id": "123"
},
"effective_principal": {
"type": "front_office",
"class": "User",
"id": "456"
},
"alaner_admin": true,
"impersonated": true,
"impersonation_mode": "read_write",
"session_id": "...",
"session_scopes": ["read", "write"],
"cloudflare_ray_id": "...",
"cloudflare_ip_country": "FR"
}
}
| Field | Description |
|---|---|
id |
UUID matching TransactionAuthContext.id - use for cross-referencing |
authenticated |
Whether a principal is set |
real_principal |
The actor performing the action |
effective_principal |
Subject of the action (same as real, or impersonated) |
alaner_admin |
True if real_principal is back_office |
impersonated |
Whether effective != real principal |
impersonation_mode |
Type of impersonation if applicable |
session_id |
Current session identifier |
session_scopes |
Session permission scopes |
cloudflare_* |
Request metadata from Cloudflare |
Cross-Referencing Logs and Database¶
Use authnz.id to join log entries with database audit records:
-- Find all DB changes made during a specific request
SELECT a.*, t.issued_at, tac.real_alaner_id
FROM activity a
JOIN transaction t ON a.transaction_id = t.id
JOIN transaction_auth_context tac ON t.auth_context_id = tac.id
WHERE tac.id = '550e8400-e29b-41d4-a716-446655440000';
In Datadog/log aggregation: