Sigra.Audit (Sigra v1.20.0)

Copy Markdown View Source

Structured audit logging for Sigra.

See .planning/phases/09-audit-logging/09-CONTEXT.md for the 28 decisions that shape this module. Summary:

  • Direct Ecto.Multi writes (D-01) — not telemetry subscribers (D-02)
  • Public API enforces reserved prefixes (D-17..D-18); internal __log_internal__/3 bypasses this check for library-owned events
  • Metadata size cap 8KB default (D-20); forbidden keys rejected (D-23)
  • Cursor pagination, no offset, or-expanded tiebreak (D-13)
  • Telemetry passthrough [:sigra, :audit, :log] on successful commit (D-24)

Telemetry responsibility for log_multi/3 and log_multi_safe/3

Standalone log/3 fires telemetry automatically from its {:ok, _} branch.

Callers of log_multi/3 / log_multi_safe/3 / __log_internal__/3 own the enclosing repo.transaction/1 and must invoke Sigra.Audit.emit_telemetry_from_changes/2 from the {:ok, changes} branch. This guarantees telemetry NEVER fires when the enclosing transaction rolls back. See Plan 03 integration sites for examples.

By default emit_telemetry_from_changes/2 looks for the :audit step only (the default Ecto.Multi operation name used when :audit_multi_step is omitted). When you append multiple internal audit inserts, pass a distinct :audit_multi_step atom per log_multi_safe/3 / __log_internal__/3 call, then pass the same atoms as the second argument to emit_telemetry_from_changes/2 so each committed audit row emits [:sigra, :audit, :log] exactly once.

Summary

Functions

Deletes audit events older than the configured retention window.

Returns the number of rows matching the given filters.

Emits [:sigra, :audit, :log] telemetry for committed audit rows in an Ecto.Multi success changes map.

Returns a cursor-paginated result map.

Writes a single audit event in its own transaction/insert.

Appends an :audit step to an existing Ecto.Multi.

Safe Multi-append for library-internal integration sites.

Safe standalone log for library-internal integration sites.

Library-internal safe audit emission with optional scope.

Returns a composable Ecto.Query filtered by the given keyword filters.

Returns an Enumerable.t() suitable for use inside the caller's repo.transaction/1 block.

Types

opts()

@type opts() :: keyword()

Functions

cleanup(opts)

@spec cleanup(keyword()) :: :ok

Deletes audit events older than the configured retention window.

count(filters, opts)

@spec count(keyword(), keyword()) :: non_neg_integer()

Returns the number of rows matching the given filters.

emit_telemetry_from_changes(changes, audit_steps \\ [:audit])

@spec emit_telemetry_from_changes(map(), [atom()]) :: :ok

Emits [:sigra, :audit, :log] telemetry for committed audit rows in an Ecto.Multi success changes map.

audit_steps is the ordered list of Ecto.Multi operation names used with :audit_multi_step (defaulting to a single :audit insert). For each step that resolves to a struct in changes, emits one telemetry event.

Callers must invoke this only from the {:ok, changes} branch of their own repo.transaction/1 so telemetry never fires on rollback.

list(filters, opts)

@spec list(keyword(), keyword()) :: %{
  entries: [struct()],
  next_cursor: String.t() | nil
}

Returns a cursor-paginated result map.

Options:

  • :repo (required)
  • :limit — default 50, capped at 500
  • :cursor — opaque Base64URL cursor from a previous result

log(action, opts)

@spec log(String.t(), opts()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}

Writes a single audit event in its own transaction/insert.

Returns {:ok, event} on success, {:error, changeset} on validation failure. Fires [:sigra, :audit, :log] telemetry exactly once on success, never on failure.

log_multi(multi, action, opts)

@spec log_multi(Ecto.Multi.t(), String.t(), opts()) :: Ecto.Multi.t()

Appends an :audit step to an existing Ecto.Multi.

Raises ArgumentError at composition time if action uses a reserved Sigra prefix — developers must not log library-owned events. Internal callers must use __log_internal__/3 instead.

Callers own the enclosing repo.transaction/1 call. To emit telemetry on successful commit, invoke emit_telemetry_from_changes/1 in the {:ok, changes} branch after the transaction returns. Telemetry MUST NOT fire when the enclosing transaction rolls back.

log_multi_safe(multi, action, opts)

@spec log_multi_safe(Ecto.Multi.t(), String.t(), opts()) :: Ecto.Multi.t()

Safe Multi-append for library-internal integration sites.

Returns the multi unchanged when :audit_schema is nil or absent. Otherwise appends an audit insert step via __log_internal__/3.

Options

  • :audit_multi_stepEcto.Multi operation name for this insert (default :audit). Use distinct atoms when composing multiple audit rows in one transaction (for example mfa.verify.success + mfa.backup_code_used).

    multi =
      Ecto.Multi.new()
      |> Sigra.Audit.log_multi_safe(
        "mfa.verify.success",
        Keyword.merge(opts, audit_multi_step: :audit_mfa_verify)
      )
      |> Sigra.Audit.log_multi_safe(
        "mfa.backup_code_used",
        Keyword.merge(opts, audit_multi_step: :audit_mfa_backup)
      )
    
    {:ok, changes} = repo.transaction(multi)
    Sigra.Audit.emit_telemetry_from_changes(changes, [:audit_mfa_verify, :audit_mfa_backup])
  • :target_resolver — arity-1 callback receiving the Multi changes map (same as :actor_resolver) to populate target_id when it cannot be known at composition time.

  • :organization_id_resolver / :effective_user_id_resolver — same arity-1 changes callbacks for scope-derived columns when the subject row is only known after an earlier Multi step (e.g. email-change confirm).

log_safe(action, opts)

@spec log_safe(String.t(), opts()) :: :ok

Safe standalone log for library-internal integration sites.

No-ops (returns :ok) when :audit_schema is nil or absent. This lets integration call sites (Plan 03) add audit writes without breaking host apps that have not configured an audit schema. Always bypasses the reserved-prefix check (library-owned events).

Returns :ok in all cases (successful insert, disabled, or insert error) because integration call sites must not change their return shape on audit failure. Errors are logged via telemetry metadata on the separate [:sigra, :audit, :log_safe_error] event for observability.

log_safe(action, scope, opts)

@spec log_safe(String.t(), scope :: term() | nil, opts()) :: :ok

Library-internal safe audit emission with optional scope.

scope is the second positional argument — mirrors the Phoenix 1.8 scopes idiom. Pass nil explicitly for pre-authentication or truly anonymous call sites.

Scope is duck-typed on %{user, active_organization, impersonating_from} — it does NOT pattern-match on %Sigra.Scope{} because that struct is generated into the host app, not defined in the library.

effective_user_id is the authenticated principal (or v1.2 impersonation target); target_id is the subject of the event. They diverge for anonymous-actor events (failed login, magic link request) and for admin actions on other users (v1.2).

Caller-supplied :organization_id / :effective_user_id / :actor_id in opts always win over scope-derived values (D-06 caller-wins merge).

query(filters)

@spec query(keyword()) :: Ecto.Query.t()

Returns a composable Ecto.Query filtered by the given keyword filters.

Required key: :audit_schema. Supported filters are listed in Sigra.Audit.Query.

stream(filters, opts)

@spec stream(keyword(), keyword()) :: Enumerable.t()

Returns an Enumerable.t() suitable for use inside the caller's repo.transaction/1 block.

The repo must implement stream/1. Raises ArgumentError otherwise so that large audit tables can never be loaded entirely into memory via a silent repo.all/1 fallback. Callers without a streaming repo should use list/2 (cursor-paginated) instead.