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 ->
case Keyword.get(opts, :record, :after) do
:before ->
entries = insert_all!.(:before)
wrap(fun.(), entries)
:after ->
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
end)
case result do
{:ok, {original, entries}} ->
Enum.each(entries, ¬ice/1)
original
{:error, {:returned, original}} ->
original
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 """
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 "The log line for a committed entry."
@spec notice(Entry.t()) :: :ok
def notice(%Entry{} = entry) do
where =
[entry.target_label, entry.repository_label || entry.organization_label]
|> Enum.reject(&is_nil/1)
|> Enum.join(" in ")
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 attrs["clone_addr"] || 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
# ── 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`,
and `:actor_label` / `:repository_label` for entries whose account or
repository is gone.
`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?} =
case cursor do
{:after, id} ->
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}
before ->
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
rows = Repo.preload(rows, [:actor])
cursors =
case {cursor, rows} do
{_, []} ->
%{older: nil, newer: nil}
{{:after, _}, rows} ->
%{older: List.last(rows).id, newer: if(more?, do: hd(rows).id)}
{{:before, _}, rows} ->
%{older: if(more?, do: List.last(rows).id), newer: hd(rows).id}
{nil, rows} ->
%{older: if(more?, do: List.last(rows).id), newer: nil}
end
{rows, cursors}
end
@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)
# 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)
_, 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}"}
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
16.0 KiB · text
History
ba66a27