20.9 KiB · text History 4f53e42
defmodule GitGud.Audit do
  @moduledoc """
  Who changed what, in an organization, a repository or the instance.

  An audit entry is written in the same transaction as the change it
  describes: a change that fails leaves no entry, and an entry that
  can't be written undoes the change. That's the difference from
  `GitGud.Events`, which is issue and PR history and best-effort by
  design — a missing audit entry is a missing security record.

      Audit.transact(scope, "repo_secret.set",
        fn -> Secrets.put_repo_secret(repo, name, value) end,
        fn _secret -> %{repository: repo, target_label: name} end)

  The call site — the LiveView handler or controller that knows who
  acted — wraps the context call; contexts don't take an actor. The
  actor is a `GitGud.Accounts.Scope` (a signed-in request, with its
  address and user agent) or a `GitGud.Audit.Actor`.

  What may be recorded is `GitGud.Audit.Catalog`'s business. Values of
  secrets, tokens and the like never are: metadata keys outside an
  action's list are dropped.

  Every committed entry also leaves one `Logger.notice` with
  `audit: true` metadata, for operators who ship logs. Entries are never
  federated. They are kept forever unless
  `config :git_gud, GitGud.Audit, retention_days: N` says otherwise
  (`GitGud.Audit.Workers.Prune`).
  """

  import Ecto.Query, warn: false

  require Logger

  alias GitGud.Accounts.Scope
  alias GitGud.Accounts.User
  alias GitGud.Audit.Actor
  alias GitGud.Audit.Entry
  alias GitGud.Organizations.Organization
  alias GitGud.Repo
  alias GitGud.Repositories.Repository

  @type actor :: Scope.t() | Actor.t()

  @typedoc """
  What an entry is about, as `describe` returns it or `insert!/3` takes it:

    * `:organization` / `:repository` — the structs; ids and labels are
      taken from them, and a repository's organization fills in
      `:organization` when it isn't given
    * `:target_type`, `:target_id`, `:target_label` — the thing acted on
      (`:target_type` defaults to the catalog's)
    * `:subject` — the `%User{}` acted on (an invitee, a banned user)
    * `:metadata` — a map; only the catalog's keys survive
  """
  @type attrs :: map()

  @doc """
  Run `fun` and record `action` in one transaction.

  `fun` returns `:ok`, `{:ok, value}`, `:error` or `{:error, reason}`,
  and `transact/5` returns exactly that. On success `describe.(value)`
  gives the entry's attrs, or `:skip` when nothing actually changed.
  Any error rolls the transaction back and records nothing; an entry
  that can't be inserted raises and rolls the change back.

  Options:

    * `record: :before` — insert the entry first and run `fun` second
      (`describe` gets `:before`). For changes with effects outside the
      database — deleting a repository removes its directory inside the
      transaction, and an insert failing after that would leave the files
      gone and the row back.
  """
  @spec transact(actor(), String.t(), (-> term()), (term() -> attrs() | :skip), keyword()) ::
          term()
  def transact(actor, action, fun, describe, opts \\ [])
      when is_function(fun, 0) and is_function(describe, 1) do
    transact_all(
      actor,
      [action],
      fun,
      fn value ->
        case describe.(value) do
          :skip -> []
          attrs -> [{action, attrs}]
        end
      end,
      opts
    )
  end

  @doc """
  `transact/5` for a change that records several entries — a settings
  save that also changed visibility, a fork that leaves an entry on
  both repositories. `actions` lists every action `describe` may
  return, so each is checked before anything changes; `describe`
  returns `[{action, attrs}]`, empty when nothing changed.
  """
  @spec transact_all(
          actor(),
          [String.t()],
          (-> term()),
          (term() -> [{String.t(), attrs()}]),
          keyword()
        ) :: term()
  def transact_all(actor, actions, fun, describe, opts \\ [])
      when is_list(actions) and is_function(fun, 0) and is_function(describe, 1) do
    bases = Map.new(actions, &{&1, base!(actor, &1)})

    insert_all! = fn value ->
      for {action, attrs} <- describe.(value) do
        base =
          Map.get(bases, action) || raise ArgumentError, "#{action} not in #{inspect(actions)}"

        insert_described!(base, attrs)
      end
    end

    result =
      Repo.transact(fn -> run_recorded(Keyword.get(opts, :record, :after), fun, insert_all!) end)

    case result do
      {:ok, {original, entries}} ->
        Enum.each(entries, &notice/1)
        original

      {:error, {:returned, original}} ->
        original
    end
  end

  defp run_recorded(:before, fun, insert_all!) do
    entries = insert_all!.(:before)
    wrap(fun.(), entries)
  end

  defp run_recorded(:after, fun, insert_all!) do
    case fun.() do
      ok when ok == :ok or (is_tuple(ok) and elem(ok, 0) == :ok) ->
        value = if ok == :ok, do: :ok, else: elem(ok, 1)
        {:ok, {ok, insert_all!.(value)}}

      other ->
        {:error, {:returned, other}}
    end
  end

  defp wrap(result, entries) do
    if result == :ok or (is_tuple(result) and elem(result, 0) == :ok),
      do: {:ok, {result, entries}},
      else: {:error, {:returned, result}}
  end

  @doc """
  Record `action` with no change around it: something that already
  happened (a prune, a registration recorded by its own context).
  """
  @spec record(actor(), String.t(), attrs()) :: Entry.t()
  def record(actor, action, attrs) do
    entry = insert!(actor, action, attrs)
    notice(entry)
    entry
  end

  @doc """
  `record/3` for what isn't a change to roll back — a sign-in, a
  sign-out: an entry that can't be written is logged and dropped rather
  than turning the sign-in into an error.
  """
  @spec best_effort(actor(), String.t(), attrs()) :: Entry.t() | nil
  def best_effort(actor, action, attrs) do
    record(actor, action, attrs)
  rescue
    e in [DBConnection.ConnectionError, Postgrex.Error, Ecto.InvalidChangesetError] ->
      Logger.warning("audit: #{action} not recorded: #{Exception.message(e)}")
      nil
  end

  @doc """
  Insert an entry inside a transaction the caller already has open.

  Leaves no log line, since the transaction may still roll back; call
  `notice/1` with the entry once it has committed.
  """
  @spec insert!(actor(), String.t(), attrs()) :: Entry.t()
  def insert!(actor, action, attrs) do
    actor |> base!(action) |> insert_described!(attrs)
  end

  @doc "Add an entry to an `Ecto.Multi`; `attrs` may be a function of the changes so far."
  @spec multi(Ecto.Multi.t(), term(), actor(), String.t(), attrs() | (map() -> attrs())) ::
          Ecto.Multi.t()
  def multi(%Ecto.Multi{} = multi, name, actor, action, attrs) do
    base = base!(actor, action)

    Ecto.Multi.insert(multi, name, fn changes ->
      attrs = if is_function(attrs, 1), do: attrs.(changes), else: attrs
      base |> Map.merge(entry_attrs(attrs)) |> Entry.changeset()
    end)
  end

  @doc """
  What to do with an entry once it has committed: its log line, and a
  `{:audit, id}` broadcast to each log it belongs in (`topics/1`), for
  the audit pages open on them.
  """
  @spec notice(Entry.t()) :: :ok
  def notice(%Entry{} = entry) do
    log_line(entry)

    for topic <- topics(entry),
        do: Phoenix.PubSub.broadcast(GitGud.PubSub, topic, {:audit, entry.id})

    :ok
  end

  @doc """
  The PubSub topics of the logs `entry` shows in: the instance's
  always, its org's and repository's, and the account log of the
  person it's about — or of whoever did it, for an entry about no org
  or repository.
  """
  @spec topics(Entry.t()) :: [String.t()]
  def topics(%Entry{} = e) do
    user =
      e.subject_user_id ||
        if(is_nil(e.organization_id) and is_nil(e.repository_id), do: e.actor_id)

    [
      "audit:instance",
      e.organization_id && "audit:org:#{e.organization_id}",
      e.repository_id && "audit:repo:#{e.repository_id}",
      user && "audit:user:#{user}"
    ]
    |> Enum.reject(&is_nil/1)
  end

  @doc "The topic `notice/1` broadcasts `scope`'s new entries on."
  def topic(:instance), do: "audit:instance"
  def topic({:org, %Organization{id: id}}), do: "audit:org:#{id}"
  def topic({:repo, %Repository{id: id}}), do: "audit:repo:#{id}"
  def topic({:user, %User{id: id}}), do: "audit:user:#{id}"

  defp log_line(%Entry{} = entry) do
    where =
      [entry.target_label, entry.repository_label || entry.organization_label]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" in ")

    # The metadata is for operators' structured log handlers; the default
    # text formatter deliberately leaves it out of every line.
    # credo:disable-for-next-line Credo.Check.Warning.MissedMetadataKeyInLoggerConfig
    Logger.notice(
      "audit: #{entry.action} by #{entry.actor_label}" <>
        if(where != "", do: " — #{where}", else: ""),
      audit: true,
      audit_action: entry.action,
      audit_id: entry.id
    )
  end

  @doc """
  The attrs for a `repo.created` entry, from the repository and the
  attrs it was created with: its visibility, and an import's source
  host — never the rest of the clone URL, which can carry a token.
  """
  @spec repo_created(Repository.t(), map()) :: attrs()
  def repo_created(%Repository{} = repo, attrs) do
    host =
      case attr(attrs, :clone_addr) do
        url when is_binary(url) and url != "" -> URI.parse(url).host
        _ -> nil
      end

    %{
      repository: repo,
      target_id: repo.id,
      metadata:
        Map.reject(%{visibility: repo.visibility, imported_from_host: host}, &is_nil(elem(&1, 1)))
    }
  end

  @doc """
  Record approving or rejecting a pull request's fork runs, once it's
  done and only if there were runs. After the fact rather than in a
  transaction: each run is approved or cancelled in its own, and
  runners are told as each one commits.
  """
  @spec fork_runs(actor(), :approved | :rejected, struct(), list()) :: Entry.t() | nil
  def fork_runs(_actor, _verdict, _pr, []), do: nil

  def fork_runs(actor, verdict, pr, runs) do
    repo = Repo.get!(Repository, pr.repository_id)

    record(actor, "ci.fork_runs_#{verdict}", %{
      repository: repo,
      target_id: pr.id,
      target_label: "##{pr.number}",
      metadata: %{pull_request: pr.number, runs: length(runs)}
    })
  end

  @doc """
  Whether `user` may read `scope`'s log: an org's, its admins; a
  repository's, its owner when it's personal, or its org's admins when
  it isn't (a team or collaborator admin doesn't); everything, instance
  admins. Sudo mode, which the pages also ask for, is theirs to check.
  """
  @spec readable?(User.t() | nil, {:org, Organization.t()} | {:repo, Repository.t()} | :instance) ::
          boolean()
  def readable?(nil, _scope), do: false
  def readable?(%User{is_admin: true}, _scope), do: true
  def readable?(_user, :instance), do: false

  def readable?(%User{} = user, {:org, %Organization{} = org}),
    do: GitGud.Organizations.org_admin?(org, user)

  def readable?(%User{id: uid}, {:repo, %Repository{organization_id: nil, owner_id: uid}}),
    do: true

  def readable?(%User{} = user, {:repo, %Repository{organization_id: oid}}) when is_integer(oid),
    do: GitGud.Organizations.org_admin?(%Organization{id: oid}, user)

  def readable?(_user, _scope), do: false

  @doc """
  `list/4` filters for the handle of whoever acted: the account's
  entries if it exists, otherwise the ones recorded under that label,
  so a deleted account's history can still be picked out.
  """
  def actor_filter(nil), do: %{}

  def actor_filter(handle) when is_binary(handle) do
    case GitGud.Accounts.get_user_by_handle(handle) do
      %User{id: id} -> %{actor_id: id}
      nil -> %{actor_label: handle}
    end
  end

  @doc "`actor_filter/1` for an organization, by handle."
  def organization_filter(nil), do: %{}

  def organization_filter(handle) when is_binary(handle) do
    case GitGud.Organizations.get_organization_by_handle(handle) do
      %Organization{id: id} -> %{organization_id: id}
      nil -> %{organization_label: "@" <> String.downcase(handle)}
    end
  end

  @doc "`actor_filter/1` for one of `org`'s repositories, by name."
  def repository_filter(_org, nil), do: %{}

  def repository_filter(%Organization{} = org, name) when is_binary(name) do
    case Repo.get_by(Repository, organization_id: org.id, name: name) do
      %Repository{id: id} -> %{repository_id: id}
      nil -> %{repository_label: "#{org.handle}/#{name}"}
    end
  end

  # ── reading ──────────────────────────────────────────────────────────

  @default_limit 50
  @max_limit 100

  @doc """
  A page of entries, newest first.

  `scope` is `{:org, org}` (the org's own entries and its repositories'),
  `{:repo, repo}`, `:instance` (everything) or `{:user, user}` (what was
  done to them, and what they did outside any org or repository).

  Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`,
  `:actor_label` / `:repository_label` / `:organization_label` for
  entries whose account, repository or org is gone, and
  `instance_only: true` for what belongs to no org or repository.
  `cursor` is nil, `{:before, id}` (older) or `{:after, id}` (newer).
  Returns `{entries, %{older: id | nil, newer: id | nil}}`: the cursors
  for the next page in each direction, nil when there's none.
  """
  def list(scope, filters \\ %{}, cursor \\ nil, limit \\ @default_limit) do
    limit = limit |> max(1) |> min(@max_limit)

    base =
      Entry
      |> scoped(scope)
      |> filtered(filters)

    {rows, more?} = fetch_page(base, cursor, limit)
    rows = Repo.preload(rows, [:actor])

    {rows, page_cursors(cursor, rows, more?)}
  end

  defp fetch_page(base, {:after, id}, limit) do
    rows =
      from(e in base, where: e.id > ^id, order_by: [asc: e.id], limit: ^(limit + 1))
      |> Repo.all()

    {Enum.reverse(Enum.take(rows, limit)), length(rows) > limit}
  end

  defp fetch_page(base, before, limit) do
    query = from(e in base, order_by: [desc: e.id], limit: ^(limit + 1))

    query =
      case before do
        {:before, id} -> from(e in query, where: e.id < ^id)
        _ -> query
      end

    rows = Repo.all(query)
    {Enum.take(rows, limit), length(rows) > limit}
  end

  defp page_cursors(_cursor, [], _more?), do: %{older: nil, newer: nil}

  defp page_cursors({:after, _}, rows, more?),
    do: %{older: List.last(rows).id, newer: if(more?, do: hd(rows).id)}

  defp page_cursors({:before, _}, rows, more?),
    do: %{older: if(more?, do: List.last(rows).id), newer: hd(rows).id}

  defp page_cursors(nil, rows, more?),
    do: %{older: if(more?, do: List.last(rows).id), newer: nil}

  @doc """
  The names of `org`'s repositories that are gone but still have
  entries, recorded under their old `handle/name`.
  """
  @spec deleted_repository_names(Organization.t()) :: [String.t()]
  def deleted_repository_names(%Organization{id: id}) do
    from(e in Entry,
      where: e.organization_id == ^id and is_nil(e.repository_id),
      where: not is_nil(e.repository_label),
      distinct: true,
      select: e.repository_label
    )
    |> Repo.all()
    |> Enum.map(&(&1 |> String.split("/", parts: 2) |> List.last()))
  end

  defp scoped(query, {:org, %Organization{id: id}}),
    do: from(e in query, where: e.organization_id == ^id)

  defp scoped(query, {:repo, %Repository{id: id}}),
    do: from(e in query, where: e.repository_id == ^id)

  defp scoped(query, {:user, %User{id: id}}),
    do:
      from(e in query,
        where:
          e.subject_user_id == ^id or
            (e.actor_id == ^id and is_nil(e.organization_id) and is_nil(e.repository_id))
      )

  defp scoped(query, :instance), do: query

  defp filtered(query, filters) do
    Enum.reduce(filters, query, fn
      {:category, c}, q when is_binary(c) ->
        from(e in q, where: e.category == ^c)

      {:actor_id, id}, q when is_integer(id) ->
        from(e in q, where: e.actor_id == ^id)

      {:repository_id, id}, q when is_integer(id) ->
        from(e in q, where: e.repository_id == ^id)

      {:organization_id, id}, q when is_integer(id) ->
        from(e in q, where: e.organization_id == ^id)

      {:id, id}, q when is_integer(id) ->
        from(e in q, where: e.id == ^id)

      # A deleted account's or repository's entries, by the label
      # they were recorded under.
      {:actor_label, label}, q when is_binary(label) ->
        from(e in q, where: is_nil(e.actor_id) and e.actor_label == ^label)

      {:repository_label, label}, q when is_binary(label) ->
        from(e in q, where: is_nil(e.repository_id) and e.repository_label == ^label)

      {:organization_label, label}, q when is_binary(label) ->
        from(e in q, where: is_nil(e.organization_id) and e.organization_label == ^label)

      # What belongs to the instance itself, not to any org or repository.
      {:instance_only, true}, q ->
        from(e in q,
          where: is_nil(e.organization_id) and is_nil(e.repository_id),
          where: is_nil(e.organization_label) and is_nil(e.repository_label)
        )

      _, q ->
        q
    end)
  end

  # ── retention ────────────────────────────────────────────────────────

  @doc """
  Delete entries written before `cutoff`, `batch` at a time so no one
  statement holds its locks for long. Returns how many went.
  """
  @spec prune(DateTime.t(), pos_integer()) :: non_neg_integer()
  def prune(%DateTime{} = cutoff, batch \\ 5_000) do
    prune_batches(cutoff, batch, 0)
  end

  defp prune_batches(cutoff, batch, total) do
    ids = from(e in Entry, where: e.inserted_at < ^cutoff, select: e.id, limit: ^batch)
    {count, _} = Repo.delete_all(from(e in Entry, where: e.id in subquery(ids)))

    if count < batch, do: total + count, else: prune_batches(cutoff, batch, total + count)
  end

  @doc """
  The configured retention in days, nil to keep everything. Raises on
  anything else, so a typo can't quietly delete the trail; checked at
  boot by `GitGud.Application`.
  """
  @spec retention_days!() :: pos_integer() | nil
  def retention_days! do
    case Keyword.get(Application.get_env(:git_gud, __MODULE__, []), :retention_days) do
      nil ->
        nil

      days when is_integer(days) and days > 0 ->
        days

      other ->
        raise ArgumentError,
              "config :git_gud, GitGud.Audit, retention_days: must be nil or a positive integer, " <>
                "got: #{inspect(other)}"
    end
  end

  # ── building ─────────────────────────────────────────────────────────

  # Who and what, checked before the change runs: an unknown action or
  # an actor that can't be described raises with nothing changed.
  defp base!(actor, action) do
    if GitGud.Audit.Catalog.fetch(action) == nil,
      do:
        raise(
          ArgumentError,
          "unknown audit action #{inspect(action)}; add it to GitGud.Audit.Catalog"
        )

    Map.put(actor_attrs(actor), :action, action)
  end

  defp actor_attrs(%Scope{user: %User{} = user} = scope),
    do: %{
      actor_type: "user",
      actor_id: user.id,
      actor_label: user.handle,
      via: "web",
      ip: scope.ip,
      user_agent: scope.user_agent
    }

  defp actor_attrs(%Actor{} = actor),
    do: %{
      actor_type: actor.type,
      actor_id: actor.id,
      actor_ref: actor.ref,
      actor_label: actor.label,
      via: actor.via,
      ip: actor.ip,
      user_agent: actor.user_agent
    }

  defp actor_attrs(other),
    do:
      raise(
        ArgumentError,
        "an audit actor is a signed-in Scope or an Audit.Actor, got: #{inspect(other)}"
      )

  defp insert_described!(_base, :skip), do: nil

  defp insert_described!(base, attrs) when is_map(attrs) do
    base
    |> Map.merge(entry_attrs(attrs))
    |> Entry.changeset()
    |> Repo.insert!()
  end

  defp entry_attrs(attrs) do
    {repo, attrs} = Map.pop(attrs, :repository)
    {org, attrs} = Map.pop(attrs, :organization)
    {subject, attrs} = Map.pop(attrs, :subject)

    attrs
    |> Map.merge(repository_attrs(repo))
    |> Map.merge(organization_attrs(org || repository_org(repo)))
    |> Map.merge(if subject, do: %{subject_user_id: subject.id}, else: %{})
  end

  defp repository_attrs(nil), do: %{}

  defp repository_attrs(%Repository{} = repo) do
    repo = Repo.preload(repo, [:owner, :organization])

    %{
      repository_id: repo.id,
      repository_label: "#{GitGud.Repositories.Storage.repo_handle(repo)}/#{repo.name}"
    }
  end

  defp repository_org(%Repository{organization_id: id} = repo) when not is_nil(id),
    do: Repo.preload(repo, :organization).organization

  defp repository_org(_), do: nil

  defp organization_attrs(nil), do: %{}

  defp organization_attrs(%Organization{} = org),
    do: %{organization_id: org.id, organization_label: "@#{org.handle}"}

  # Attrs arrive string-keyed from forms and atom-keyed from code.
  defp attr(attrs, key), do: attrs[Atom.to_string(key)] || attrs[key]
end