4.0 KiB · text History b868907
defmodule GitGud.Audit.Entry do
  @moduledoc """
  One audit log row. Append-only: there is no update changeset, and the
  only delete is `GitGud.Audit.prune/2`.

  The `*_label` fields are snapshots taken when the entry was written,
  so an entry still reads correctly after its actor, organization,
  repository or target is renamed or gone.
  """
  use Ecto.Schema
  import Ecto.Changeset

  require Logger

  alias GitGud.Audit.Catalog

  @actor_types ~w(user runner job system federated)
  @vias ~w(web api mobile_api runner git job cron)

  @max_string 200
  @max_list 50

  schema "audit_entries" do
    field :action, :string
    field :category, :string

    field :actor_type, :string
    belongs_to :actor, GitGud.Accounts.User
    field :actor_ref, :integer
    field :actor_label, :string
    field :via, :string

    belongs_to :organization, GitGud.Organizations.Organization
    field :organization_label, :string
    belongs_to :repository, GitGud.Repositories.Repository
    field :repository_label, :string

    field :target_type, :string
    field :target_id, :integer
    field :target_label, :string
    belongs_to :subject_user, GitGud.Accounts.User

    field :metadata, :map, default: %{}
    field :ip, :string
    field :user_agent, :string

    field :inserted_at, :utc_datetime_usec, read_after_writes: true
  end

  @fields ~w(action actor_type actor_id actor_ref actor_label via organization_id
             organization_label repository_id repository_label target_type target_id
             target_label subject_user_id ip user_agent)a

  @doc """
  Build an entry. `action` must be in `GitGud.Audit.Catalog`; its
  category and target type come from there, and metadata keys it
  doesn't list are dropped (logged by name, never by value).
  """
  def changeset(attrs) do
    action = attrs[:action]

    %__MODULE__{}
    |> cast(attrs, @fields)
    |> validate_required([:action, :actor_type, :actor_label])
    |> validate_inclusion(:actor_type, @actor_types)
    |> validate_inclusion(:via, @vias)
    |> put_definition(Catalog.fetch(action))
    |> put_change(:metadata, metadata(action, attrs[:metadata] || %{}))
    |> update_change(:user_agent, &String.slice(&1, 0, 512))
    |> update_change(:actor_label, &String.slice(&1, 0, 255))
    |> update_change(:target_label, &String.slice(&1, 0, 255))
  end

  defp put_definition(changeset, nil), do: add_error(changeset, :action, "is not in the catalog")

  defp put_definition(changeset, %{category: category, target: target}) do
    changeset
    |> put_change(:category, category)
    |> then(fn cs ->
      if target && get_field(cs, :target_type) == nil,
        do: put_change(cs, :target_type, target),
        else: cs
    end)
  end

  # Only the catalog's keys, only plain values: strings (truncated),
  # numbers, booleans, nil, and short lists of those.
  defp metadata(action, metadata) when is_map(metadata) do
    allowed = (Catalog.fetch(action) || %{keys: []}).keys

    Enum.reduce(metadata, %{}, fn {key, value}, acc ->
      key = to_string(key)

      if key in allowed do
        case plain(value) do
          :drop ->
            Logger.warning(
              "audit #{action}: dropped metadata key #{inspect(key)} (not a plain value)"
            )

            acc

          clean ->
            Map.put(acc, key, clean)
        end
      else
        Logger.warning(
          "audit #{action}: dropped metadata key #{inspect(key)} (not in the catalog)"
        )

        acc
      end
    end)
  end

  defp metadata(_action, _), do: %{}

  defp plain(v) when is_binary(v), do: String.slice(v, 0, @max_string)
  defp plain(v) when is_number(v) or is_boolean(v) or is_nil(v), do: v
  defp plain(v) when is_atom(v), do: Atom.to_string(v)
  defp plain(%DateTime{} = v), do: DateTime.to_iso8601(v)

  defp plain(v) when is_list(v) and length(v) <= @max_list do
    values = Enum.map(v, &plain/1)
    if Enum.any?(values, &(&1 == :drop or is_list(&1))), do: :drop, else: values
  end

  defp plain(_), do: :drop

  def actor_types, do: @actor_types
end