Skip to content

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
Hold "Alt" / "Option" to enable pan & zoom

Key concepts:

  • current_auth_context.id - UUID generated per request, used as linking key
  • TransactionAuthContext - Database table storing auth context snapshot
  • Transaction / Activity - Versioning tables that reference TransactionAuthContext
  • authnz log 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
Hold "Alt" / "Option" to enable pan & zoom
Relationship Description
Transaction.auth_context_idTransactionAuthContext.id Links transaction to auth context
Activity.transaction_idTransaction.id Links row changes to transaction
*ActorTrackableMixin.auth_context_idTransactionAuthContext.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)
Hold "Alt" / "Option" to enable pan & zoom
  1. Request starts → Anonymous AuthContext created with new UUID
  2. Auth provider authenticatesset_auth_context() stores principal info
  3. First DB flush with changes:
  4. TransactionAuthContext inserted using ON CONFLICT DO NOTHING (idempotent)
  5. Transaction inserted with auth_context_id
  6. PostgreSQL audit triggers create Activity rows
  7. Request ends → Context destroyed (Flask g scope 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.pybuild_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:

@authnz.id:"550e8400-e29b-41d4-a716-446655440000"