Skip to content

Repository files navigation

AuditLog

Hex.pm HexDocs CI License

AuditLog records explicit semantic audit events with Ecto and PostgreSQL. An event states who performed a business action, what the action affected, why it happened, and which operation it belonged to.

The application chooses when to record an event. AuditLog does not infer intent from changesets or database writes.

Example

Write the domain change and its event in the same database transaction:

actor = AuditLog.Actor.new!("user", current_user.id, label: current_user.email)
context = AuditLog.Context.new!(actor)

Repo.transaction(fn ->
  shareholder =
    shareholder
    |> Shareholder.changeset(%{address: corrected_address})
    |> Repo.update!()

  subject = AuditLog.Subject.from_schema!(shareholder, type: "shareholder")

  AuditLog.record!(
    Repo,
    "shareholder.address_corrected",
    context,
    subject,
    reason: "Corrected from the signed source document"
  )

  shareholder
end)

Repo commits the update and audit event together. If any operation in the callback raises, Repo rolls back the transaction. AuditLog does not start a hidden transaction. AuditLog creates no audit event when an application path omits record/5.

Requirements

AuditLog supports:

  • Elixir 1.15 or later;
  • Ecto SQL 3.11 through 3.14;
  • Postgrex 0.19 or later, before 1.0; and
  • PostgreSQL.

The CI workflow defines the PostgreSQL versions tested for each supported Elixir and Ecto SQL combination. AuditLog uses PostgreSQL-specific SQL and does not support other Ecto adapters.

Installation

Add audit_log to mix.exs:

def deps do
  [
    {:audit_log, "~> 0.3.1"}
  ]
end

Fetch the dependency, generate the migration, and migrate the database:

mix deps.get
mix audit_log.gen.migration
mix ecto.migrate

The generator accepts the options supported by mix ecto.gen.migration. Select a repository explicitly when the application has more than one:

mix audit_log.gen.migration -r MyApp.Repo

For a PostgreSQL schema prefix, edit the generated migration:

def up, do: AuditLog.Migration.up(prefix: :audit)
def down, do: AuditLog.Migration.down(prefix: :audit)

The schema must already exist. A prefix may contain lowercase ASCII letters, digits, and underscores, but it cannot start with a digit.

Event model

AuditLog stores one append-only audit_events row for each event:

Value Meaning
action Stable, application-owned business code such as invoice.sent; surrounding whitespace is invalid
actor_type, actor_id Durable identity of the person or service responsible
actor_label Optional display-label snapshot
subject_type, subject_id Durable identity of the affected business object
subject_version_at Optional lower boundary of an exact temporal row version
reason Optional operator prose explaining why the action occurred
correlation_id UUID shared by events in one request or business operation
causation_id UUID of the event that directly caused this event, when known
metadata Small, canonical JSON object containing application-owned facts
occurred_at PostgreSQL transaction start time

AuditLog stores actor and subject identifiers as text, without foreign keys. Deleting or renaming a source record leaves the identifier on an existing event unchanged.

Actors

An actor is a durable snapshot, not a join to a user table:

{:ok, user_actor} = AuditLog.Actor.new("user", user.id, label: user.email)
{:ok, worker_actor} = AuditLog.Actor.new("service", "billing-worker")

The application owns actor types and identifiers. The optional label records what an operator would have recognised when the event occurred. new/3 returns {:ok, actor} or a matchable AuditLog.InvalidValueError. Use new!/3 only when invalid programmer-owned input is exceptional.

Subjects

A subject identifies the affected business object:

{:ok, subject} = AuditLog.Subject.new("invoice", invoice.id)

{:ok, subject} =
  AuditLog.Subject.from_schema(invoice,
    type: "invoice"
  )

AuditLog.Subject.from_schema/2 accepts a loaded or deleted Ecto struct whose schema has one primary key. It rejects a nil primary-key value and schemas with no primary key or a composite primary key. Use an explicit type when a future table rename must not change the subject type.

Context

A context carries values shared by related events:

  • correlation_id groups one request or business operation;
  • causation_id identifies the event that directly caused later work; and
  • metadata contains small shared facts.

AuditLog.Context.new/2 generates a correlation UUID when the caller does not supply one. Pass the context explicitly through functions and tasks. When a job crosses a serialization boundary, store its primitive fields and rebuild the context in the worker. AuditLog uses no process dictionary, PostgreSQL session setting, ETS table, or server process.

For follow-up work, retain the correlation ID but identify the actor that performs the new action:

{:ok, child_context} =
  AuditLog.Context.child(
    parent_context,
    worker_actor,
    causing_event
  )

Child metadata shallowly overrides parent metadata. Build a new context instead when follow-up work must discard the parent metadata.

Event metadata shallowly overrides context metadata:

{:ok, context} = AuditLog.Context.new(actor, metadata: %{tenant: "acme"})

AuditLog.record(
  Repo,
  "invoice.sent",
  context,
  subject,
  metadata: %{channel: "email"}
)

AuditLog encodes and decodes metadata with Jason before storing it. Returned maps therefore contain string keys and JSON primitive values, including in nested objects. This canonical form matches metadata reloaded from PostgreSQL under the default JSON configuration.

AuditLog does not enforce a metadata size limit. The application must set and enforce that limit. Do not store complete records, secrets, access tokens, request bodies, or unbounded payloads.

reason is optional operator prose. AuditLog preserves a non-empty reason verbatim and converts an empty or whitespace-only reason to nil. Application logic should use action; it should never parse reason.

Action codes must be strings. AuditLog rejects blank action codes and action codes with surrounding whitespace. It does not silently trim or convert an invalid code.

Transactions and Ecto.Multi

AuditLog.record/5 returns {:ok, event}, an Ecto changeset error, or a matchable AuditLog.InvalidValueError for rejected boundary input. AuditLog.record!/5 returns the event or raises the corresponding validation, constraint, or boundary exception. Neither function starts a transaction.

Use Ecto.Multi.run/3 when the subject depends on an earlier operation:

Ecto.Multi.new()
|> Ecto.Multi.update(:shareholder, changeset)
|> Ecto.Multi.run(:audit_event, fn repo, %{shareholder: shareholder} ->
  with {:ok, subject} <-
         AuditLog.Subject.from_schema(shareholder,
           type: "shareholder"
         ) do
    AuditLog.record(
      repo,
      "shareholder.address_corrected",
      context,
      subject,
      reason: reason
    )
  end
end)
|> Repo.transaction()

A standalone event, such as a failed login, does not need a domain-write transaction.

Supply an event UUID when a workflow needs stable event identity across retries:

AuditLog.record!(
  Repo,
  "invoice.sent",
  context,
  subject,
  id: event_id
)

Reusing the UUID fails. AuditLog deliberately does not use on_conflict: :nothing: treating a different event with the same ID as a success would corrupt the audit record.

Queries

AuditLog.Query returns ordinary, composable Ecto queries:

events =
  AuditLog.Query.for_subject(subject.type, subject.id, order: :desc)
  |> where([event], event.action in ^visible_actions)
  |> limit(50)
  |> Repo.all()

Subject and actor filters accept the application-owned type and identifier as separate scalar arguments. The other helpers query by correlation UUID or a half-open [from, to) time window. Every query orders by occurred_at, id. The UUID provides a stable display tie-breaker for events in one transaction; UUID order does not prove insertion or causal order.

Use AuditLog.Subject.cast_id/2 before querying an application schema whose primary key is not text:

{:ok, shareholder_id} =
  AuditLog.Subject.cast_id(Shareholder, event.subject_id)

Temporal subjects

AuditLog can link an event to an exact row version without depending on a temporal-table library. For a compatible schema, AuditLog.Subject.from_schema/2 reads sys_period.from into subject_version_at.

That boundary identifies the affected row version. occurred_at does not: it is the audit-event transaction's start time. See Temporal subjects for insert, update, delete, and ID-casting guidance.

Guarantees and limits

AuditLog is not database-wide change capture. Direct SQL, migrations, ETL, replication workers, and application paths that omit record/5 create no semantic event.

It is also not:

  • event sourcing or state reconstruction;
  • a transactional outbox or message publisher;
  • authentication or authorisation;
  • a field-diff engine;
  • tamper evidence; or
  • retention automation.

An ENABLE ALWAYS trigger rejects ordinary UPDATE and DELETE, including DML performed with session_replication_role = replica. A table owner or superuser can still use DDL, disable the trigger, truncate or drop the table, or otherwise administer the data.

Read Limits and operations before deploying AuditLog.

Project

See the changelog for releases and the roadmap for planned companion packages.

AuditLog is released under the MIT License.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages