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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
4.0 KiB · text
History
b868907