defmodule GitGudWeb.AuditComponents do
@moduledoc """
One audit entry as a row: when, who, what, and where.
`describe/1` has a clause per `GitGud.Audit.Catalog` action; one it
doesn't know yet shows as the raw action rather than breaking the
page, so recording can land before its wording does.
"""
use GitGudWeb, :html
import GitGudWeb.DashboardComponents, only: [ago: 1]
alias GitGud.Audit.Entry
@doc "The action as a phrase, without its actor."
@spec describe(Entry.t()) :: String.t()
# One flat clause per catalog action reads best as a single table.
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def describe(%Entry{action: action} = entry) do
case action do
"repo_secret.set" ->
"set the secret"
"repo_secret.deleted" ->
"deleted the secret"
"repo_variable.set" ->
"set the variable"
"repo_variable.deleted" ->
"deleted the variable"
"org_secret.set" ->
"set the organization secret"
"org_secret.deleted" ->
"deleted the organization secret"
"org_variable.set" ->
"set the organization variable"
"org_variable.deleted" ->
"deleted the organization variable"
"org.invitation_sent" ->
"invited #{entry.metadata["role"] || "member"}"
"org.invitation_withdrawn" ->
"withdrew the invitation for"
"org.invitation_accepted" ->
"accepted the invitation as #{entry.metadata["role"]}"
"org.invitation_declined" ->
"declined the invitation"
"org.member_role_changed" ->
role_change(entry.metadata)
"org.member_removed" ->
"removed #{entry.metadata["role"] || "member"}"
"team.created" ->
"created the team"
"org.created" ->
"created the organization (#{entry.metadata["visibility"]})"
"org.profile_updated" ->
profile_change(entry.metadata)
"org.visibility_changed" ->
"changed visibility #{from_to(entry.metadata)}"
"repo.created" ->
repo_created(entry.metadata)
"repo.forked" ->
"forked it to #{entry.metadata["destination"]}"
"repo.deleted" ->
"deleted the repository"
"repo.branch_overwritten" ->
"overwrote from upstream the branch"
"repo.settings_updated" ->
"updated the settings (#{keys(entry.metadata)})"
"repo.visibility_changed" ->
"changed visibility #{from_to(entry.metadata)}"
"repo.archived" ->
"archived the repository"
"repo.unarchived" ->
"unarchived the repository"
"repo.upstream_settings_changed" ->
upstream_change(entry.metadata)
"repo.interaction_policy_changed" ->
"changed the interaction policy #{from_to(entry.metadata)}"
"branch_protection.created" ->
"protected branches matching"
"branch_protection.deleted" ->
"removed the protection on"
"deploy_key.added" ->
deploy_key_added(entry.metadata)
"deploy_key.removed" ->
"removed the deploy key"
"webhook.created" ->
"added a webhook to"
"webhook.deleted" ->
"deleted the webhook to"
"webhook.enabled" ->
"enabled the webhook to"
"webhook.disabled" ->
"disabled the webhook to"
"registry_token.created" ->
"created the registry token (#{joined(entry.metadata["scopes"])})"
"registry_token.revoked" ->
"revoked the registry token"
"job_token_grant.created" ->
grant_created(entry.metadata)
"job_token_grant.deleted" ->
"removed job-token access for"
"runner.registration_token_minted" ->
"minted a runner registration token"
"runner.registered" ->
"registered"
"runner.created" ->
"created the runner"
"runner.deleted" ->
"deleted the runner"
"runner.architecture_changed" ->
"changed the architecture #{from_to(entry.metadata)} of"
"ci.fork_runs_approved" ->
fork_runs("approved", entry.metadata)
"ci.fork_runs_rejected" ->
fork_runs("rejected", entry.metadata)
"ban.created" ->
ban_created(entry.metadata)
"ban.lifted" ->
"lifted the ban on"
"report.dismissed" ->
"dismissed #{report(entry.metadata)}"
"report.actioned" ->
"marked handled #{report(entry.metadata)}"
"report.content_moderated" ->
"replaced the reported content of #{report(entry.metadata)}"
"instance.blocked" ->
"blocked the instance (#{entry.metadata["severity"]})"
"instance.unblocked" ->
"unblocked the instance"
"instance.allowlisted" ->
"allowlisted the instance"
"instance.unallowlisted" ->
"took off the allowlist the instance"
"suggested_block.approved" ->
"approved the suggested block of"
"suggested_block.dismissed" ->
"dismissed the suggested block of"
"remote_actor.blocked" ->
"blocked the remote actor"
"remote_actor.unblocked" ->
"unblocked the remote actor"
"remote_actor.suspended" ->
"suspended the remote actor"
"inbox.quarantine_approved" ->
"let in a quarantined delivery from"
"inbox.quarantine_rejected" ->
"rejected a quarantined delivery from"
"report.forwarded" ->
"forwarded to its source #{report(entry.metadata)}"
"report.content_restored" ->
"restored the content behind #{report(entry.metadata)}"
"instance_setting.changed" ->
"changed #{from_to(entry.metadata)} the setting"
"packages.pruned" ->
packages_pruned(entry.metadata)
"invite.created" ->
"created a registration invite"
"invite.revoked" ->
"revoked a registration invite"
"session.login" ->
"signed in#{method(entry.metadata)}"
"session.login_failed" ->
"a sign-in failed: wrong password"
"session.logout" ->
"signed out"
"sudo.entered" ->
"re-authenticated#{method(entry.metadata)}"
"password.changed" ->
"changed the password"
"email.changed" ->
"changed the email address"
"two_factor.enabled" ->
"turned on two-factor authentication"
"two_factor.disabled" ->
"turned off two-factor authentication"
"two_factor.reauth_changed" ->
reauth_changed(entry.metadata)
"recovery_codes.regenerated" ->
"generated new recovery codes"
"webauthn.added" ->
"added the security key"
"webauthn.removed" ->
"removed the security key"
"pat.created" ->
"created the access token (#{joined(entry.metadata["scopes"])})"
"pat.revoked" ->
"revoked the access token"
"ssh_key.added" ->
"added the SSH key"
"ssh_key.removed" ->
"removed the SSH key"
"org.advisories_enabled" ->
"turned on dependency alerts for the organization's repositories"
"org.advisories_disabled" ->
"turned off dependency alerts for the organization's repositories"
"repo.advisories_enabled" ->
"turned on dependency alerts"
"repo.advisories_disabled" ->
"turned off dependency alerts"
"audit.pruned" ->
"pruned #{entry.metadata["count"]} audit entries"
other ->
other
end
end
defp role_change(metadata), do: "changed the role #{from_to(metadata)} of"
defp profile_change(%{"fields" => [_ | _] = fields}),
do: "updated the profile (#{Enum.join(fields, ", ")})"
defp profile_change(_), do: "updated the profile"
defp repo_created(%{"forked_from" => from}) when is_binary(from),
do: "forked the repository from #{from}"
defp repo_created(%{"imported_from_host" => host}) when is_binary(host),
do: "imported the repository from #{host}"
defp repo_created(metadata), do: "created the repository (#{metadata["visibility"]})"
defp upstream_change(metadata) do
auto = if metadata["auto"], do: ", syncing automatically", else: ""
"set upstream sync to #{metadata["mode"]}#{auto}"
end
defp deploy_key_added(%{"read_write" => true}), do: "added the read-write deploy key"
defp deploy_key_added(_), do: "added the read-only deploy key"
defp grant_created(metadata) do
can =
[metadata["read"] && "read", metadata["dispatch"] && "dispatch"]
|> Enum.filter(& &1)
|> Enum.join(", ")
"granted job-token access (#{can}) to"
end
defp joined(values) when is_list(values), do: Enum.join(values, ", ")
defp joined(value), do: to_string(value)
defp fork_runs(verb, metadata),
do: "#{verb} #{metadata["runs"]} fork run(s) on pull request ##{metadata["pull_request"]}"
defp ban_created(%{"expires_at" => until}) when is_binary(until),
do: "banned, until #{String.slice(until, 0, 10)},"
defp ban_created(_), do: "banned"
defp report(%{"target_type" => type}) when is_binary(type), do: "a report on a #{type}"
defp report(_), do: "a report"
defp packages_pruned(metadata),
do: "pruned #{metadata["versions"]} package version(s), #{metadata["bytes"]} bytes"
@methods %{
"password" => "password",
"magic_link" => "an email link",
"totp" => "an authenticator code",
"recovery_code" => "a recovery code",
"webauthn" => "a security key"
}
defp method(%{"method" => m}) when is_map_key(@methods, m), do: " with " <> @methods[m]
defp method(_), do: ""
defp reauth_changed(%{"enabled" => true}),
do: "allowed re-authenticating with a second factor"
defp reauth_changed(_), do: "stopped re-authenticating with a second factor"
defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ")
defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})"
@doc "Category tab label."
def category_label("secrets"), do: "Secrets & variables"
def category_label("members"), do: "Members"
def category_label("settings"), do: "Settings"
def category_label("repositories"), do: "Repositories"
def category_label("branch_protection"), do: "Branch protection"
def category_label("deploy_keys"), do: "Deploy keys"
def category_label("webhooks"), do: "Webhooks"
def category_label("credentials"), do: "Tokens"
def category_label("runners"), do: "Runners"
def category_label("ci"), do: "CI"
def category_label("moderation"), do: "Moderation"
def category_label("instance"), do: "Instance"
def category_label("account"), do: "Account"
def category_label(other), do: other
@doc """
An entry's row. `show_request` adds the client address and user
agent, which only the instance view shows for everyone's entries;
`own_requests` (a user id) shows them for that user's own entries
only, as their security log does.
"""
attr :id, :string, required: true
attr :entry, Entry, required: true
attr :show_request, :boolean, default: false
attr :own_requests, :integer, default: nil
def entry_row(assigns) do
assigns =
assign(
assigns,
:show_request,
assigns.show_request or
(assigns.own_requests != nil and assigns.entry.actor_id == assigns.own_requests)
)
~H"""
<li id={@id} class="flex items-start gap-3 py-3 text-sm">
<span
class="w-24 shrink-0 text-xs opacity-60 tabular-nums"
title={DateTime.to_iso8601(@entry.inserted_at)}
>
{ago(@entry.inserted_at)}
</span>
<div class="min-w-0 flex-1 space-y-1">
<p>
<.actor entry={@entry} />
<span class="opacity-80">{describe(@entry)}</span>
<code :if={@entry.target_label} class="font-mono text-xs bg-base-200 rounded px-1">
{@entry.target_label}
</code>
<span :if={@entry.repository_label} class="opacity-70">
in <.place entry={@entry} />
</span>
</p>
<details :if={@entry.metadata != %{} or @show_request} class="text-xs opacity-70">
<summary class="cursor-pointer select-none">Details</summary>
<dl class="mt-1 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 font-mono">
<%= for {key, value} <- Enum.sort(@entry.metadata) do %>
<dt>{key}</dt>
<dd class="break-all">{render_value(value)}</dd>
<% end %>
<%= if @show_request do %>
<dt>ip</dt>
<dd>{@entry.ip || "—"}</dd>
<dt>user agent</dt>
<dd class="break-all">{@entry.user_agent || "—"}</dd>
<dt>via</dt>
<dd>{@entry.via || "—"}</dd>
<% end %>
</dl>
</details>
</div>
</li>
"""
end
attr :entry, Entry, required: true
defp actor(%{entry: %Entry{actor_type: "user", actor_id: id}} = assigns) when not is_nil(id) do
~H"""
<.link navigate={~p"/u/#{@entry.actor_label}"} class="font-medium link link-hover">
{@entry.actor_label}
</.link>
"""
end
defp actor(assigns) do
~H"""
<span class="font-medium">{@entry.actor_label}</span>
"""
end
attr :entry, Entry, required: true
# A link while the repository still exists; its label once it's gone.
defp place(%{entry: %Entry{repository_id: id}} = assigns) when not is_nil(id) do
~H"""
<.link navigate={"/r/" <> @entry.repository_label} class="link link-hover font-mono">
{@entry.repository_label}
</.link>
"""
end
defp place(assigns) do
~H"""
<span class="font-mono line-through decoration-1">{@entry.repository_label}</span>
"""
end
defp render_value(v) when is_list(v), do: Enum.map_join(v, ", ", &to_string/1)
defp render_value(nil), do: "—"
defp render_value(v), do: to_string(v)
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
13.5 KiB · text
History
b868907