Current section
Files
Jump to
Current section
Files
README.md
# AuditLog
[](https://hex.pm/packages/audit_log)
[](https://hexdocs.pm/audit_log)
[](https://github.com/UntangleDev/audit_log/actions/workflows/ci.yml)
[](https://github.com/UntangleDev/audit_log/blob/main/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:
```elixir
with {:ok, actor} <-
AuditLog.Actor.new("user", current_user.id,
label: current_user.email
),
{:ok, context} <-
AuditLog.Context.new(actor,
correlation_id: request_id
) do
Repo.transaction(fn ->
shareholder =
shareholder
|> Shareholder.changeset(%{address: corrected_address})
|> Repo.update!()
with {:ok, subject} <-
AuditLog.Subject.from_schema(shareholder,
type: "shareholder"
),
{:ok, _event} <-
AuditLog.record(
Repo,
"shareholder.address_corrected",
context,
subject,
reason: "Corrected from the signed source document",
metadata: %{fields: ["address"]}
) do
shareholder
else
{:error, error} -> Repo.rollback(error)
end
end)
end
```
The transaction commits or rolls back both writes. 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`:
```elixir
def deps do
[
{:audit_log, "~> 0.3.0"}
]
end
```
Fetch the dependency, generate the migration, and migrate the database:
```console
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:
```console
mix audit_log.gen.migration -r MyApp.Repo
```
For a PostgreSQL schema prefix, edit the generated migration:
```elixir
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:
```elixir
{: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:
```elixir
{: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:
```elixir
{: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:
```elixir
{: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:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
{: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](guides/temporal_subjects.md) 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](guides/limits.md) before deploying AuditLog.
## Project
See the [changelog](CHANGELOG.md) for releases and the [roadmap](ROADMAP.md) for
planned companion packages.
AuditLog is released under the
[MIT License](https://github.com/UntangleDev/audit_log/blob/main/LICENSE).