Fort

Drop-in, atomic audit logging for Elixir/Phoenix applications. Persists audit trail to PostgreSQL and emits structured JSON via :logger. Business transactions and their audit records are always committed atomically — enforced at the transact/4 boundary by the AuditedMulti type.

Installation

Add fort_audit to your mix.exs:

def deps do
[
{:fort_audit, "~> 0.1"}
]
end

Configure the Ecto repo in config/config.exs:

config :fort_audit, :repo, MyApp.Repo

Note:Fort.Application reads :repo at boot. If unconfigured, the host app crashes on startup — intentional fail-fast behavior.

Install and run the migration to create the audit_logs table:

mix fort.install
mix ecto.migrate

Two paths

Usage

Existing Multi (wrap at the end)

Attach audit to an already-assembled Ecto.Multi by wrapping it right before transact/4:

multi =
Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, user_params))
|> Multi.run(:profile, fn %{user: user} ->
Profile.changeset(%Profile{}, %{user_id: user.id})
|> Repo.insert()
end)
multi
|> Fort.Audit.wrap()
|> Fort.Audit.append_to_multi(:audit, %{
actor_id: actor.id,
actor_type: "admin_user",
action: "user.created",
subject_id: user.id,
subject_type: "user"
})
|> Fort.Audit.transact("user.created", actor.id, actor_type: "admin_user")

Greenfield (no existing Multi)

Start from scratch with new/0. Business steps are added by destructuring the .multi field:

Fort.Audit.new()
|> then(fn %Fort.AuditedMulti{multi: multi} ->
%{multi |
multi: multi
|> Multi.insert(:organization, Organization.changeset(%Organization{}, org_params))
|> Multi.run(:owner, fn %{organization: org} -> create_owner(org, owner_params) end)
}
end)
|> Fort.Audit.append_to_multi(:audit, %{
actor_id: actor.id,
actor_type: "admin_user",
action: "organization.created"
})
|> Fort.Audit.transact("organization.created", actor.id, actor_type: "admin_user")

Standalone log (no Multi)

For pre-Multi validation failures or non-transactional code paths:

case validate_params(params) do
:ok ->
# proceed to Fort.Audit.transact
{:error, reason} ->
Fort.Audit.log(%{
actor_id: actor.id,
actor_type: "admin_user",
action: "user.registration.rejected",
outcome: "failure",
metadata: %{reason: reason}
})
{:error, reason}
end

Audited procedural (inside Repo.transaction)

For state transitions inside Repo.transaction(fn -> ... end):

def execute(settlement_id, attrs) do
Repo.transaction(fn ->
settlement = cancel(settlement_id, attrs)
{:ok, _audit_log} = Fort.Audit.log(%{
actor_id: attrs.actor_id,
actor_type: attrs.actor_type,
action: "settlement.cancelled",
outcome: "success",
subject_id: settlement.id,
subject_type: "settlement",
changes: %{status: "cancelled"}
})
settlement
end)
end

Dynamic attrs from accumulated changes

Fort.Audit.new()
|> Multi.run(:data, fn _repo, _changes -> {:ok, %{user_id: "user-1"}} end)
|> Fort.Audit.wrap()
|> Fort.Audit.append_to_multi(:audit, fn changes ->
%{
actor_id: "actor-1",
actor_type: "system",
action: "test",
subject_id: changes.data.user_id
}
end)
|> Fort.Audit.transact("test.action", "actor-1", actor_type: "system")

Deriving before/after/changes from a changeset

Derive before_data, after_data, and changes from an Ecto.Changeset. Fields marked redact: true are stripped entirely:

changeset
|> Fort.Audit.from_changeset()
|> Map.merge(%{actor_id: actor.id, actor_type: "admin_user", action: "user.updated"})
|> then(&Fort.Audit.append_to_multi(multi, :audit, &1))

Schema

No foreign keys — audit records survive entity deletion. All IDs are plain strings.

FieldTypeRequiredDescription
actor_idstringyesWho performed the action
actor_typestringyesType of actor
subject_idstringnoWhat the action was performed on
subject_typestringnoType of subject
actionstringyesDot-notation action name
outcomestringyes"success" or "failure"
before_datajsonbnoState before the action
after_datajsonbnoState after the action
changesjsonbnoSummary of changes
metadatajsonbnoExtra context
categorystringnoDomain category
descriptionstringnoHuman-readable summary
scope_typestringnoScoping entity type
scope_idstringnoScoping entity ID

Guardrails