neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Audit: branch protection, deploy keys, webhooks, registry tokens, job-token grants
99ed362 · Gabriel Morell · 2026-10-22 16:02
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/audit/catalog.ex
+48
−0
@@ -79,6 +79,54 @@ defmodule GitGud.Audit.Catalog do
| 79 | 79 | target: "repository", |
| 80 | 80 | keys: ~w(from to) |
| 81 | 81 | }, |
| 82 | + # Repository controls. A webhook is labelled by its host alone: | |
| 83 | + # Slack- and Discord-style URLs carry their credential in the path. | |
| 84 | + # Never a deploy key's body, a token or a webhook secret. | |
| 85 | + "branch_protection.created" => %{ | |
| 86 | + category: "branch_protection", | |
| 87 | + target: "branch_protection", | |
| 88 | + keys: | |
| 89 | + ~w(required_approvals allow_force_push allow_deletions require_status_checks required_checks) | |
| 90 | + }, | |
| 91 | + "branch_protection.deleted" => %{ | |
| 92 | + category: "branch_protection", | |
| 93 | + target: "branch_protection", | |
| 94 | + keys: ~w() | |
| 95 | + }, | |
| 96 | + "deploy_key.added" => %{ | |
| 97 | + category: "deploy_keys", | |
| 98 | + target: "deploy_key", | |
| 99 | + keys: ~w(fingerprint read_write) | |
| 100 | + }, | |
| 101 | + "deploy_key.removed" => %{ | |
| 102 | + category: "deploy_keys", | |
| 103 | + target: "deploy_key", | |
| 104 | + keys: ~w(fingerprint) | |
| 105 | + }, | |
| 106 | + "webhook.created" => %{ | |
| 107 | + category: "webhooks", | |
| 108 | + target: "webhook", | |
| 109 | + keys: ~w(events content_type insecure_ssl) | |
| 110 | + }, | |
| 111 | + "webhook.deleted" => %{category: "webhooks", target: "webhook", keys: ~w()}, | |
| 112 | + "webhook.enabled" => %{category: "webhooks", target: "webhook", keys: ~w()}, | |
| 113 | + "webhook.disabled" => %{category: "webhooks", target: "webhook", keys: ~w()}, | |
| 114 | + "registry_token.created" => %{ | |
| 115 | + category: "credentials", | |
| 116 | + target: "registry_token", | |
| 117 | + keys: ~w(scopes) | |
| 118 | + }, | |
| 119 | + "registry_token.revoked" => %{category: "credentials", target: "registry_token", keys: ~w()}, | |
| 120 | + "job_token_grant.created" => %{ | |
| 121 | + category: "credentials", | |
| 122 | + target: "job_token_grant", | |
| 123 | + keys: ~w(read dispatch) | |
| 124 | + }, | |
| 125 | + "job_token_grant.deleted" => %{ | |
| 126 | + category: "credentials", | |
| 127 | + target: "job_token_grant", | |
| 128 | + keys: ~w() | |
| 129 | + }, | |
| 82 | 130 | # The pruner's own trace: how much went, from before when. |
| 83 | 131 | "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)} |
| 84 | 132 | } |
modified
lib/git_gud/packages.ex
+5
−2
@@ -58,9 +58,12 @@ defmodule GitGud.Packages do
| 58 | 58 | end |
| 59 | 59 | end |
| 60 | 60 | |
| 61 | + @doc "Revoke one of `repo`'s registry tokens: `{:ok, token}`, or `{:error, :not_found}`." | |
| 61 | 62 | def delete_registry_token(%Repository{id: rid}, id) do |
| 62 | − Repo.delete_all(from t in RegistryToken, where: t.repository_id == ^rid and t.id == ^id) | |
| 63 | − :ok | |
| 63 | + case Repo.get_by(RegistryToken, id: id, repository_id: rid) do | |
| 64 | + nil -> {:error, :not_found} | |
| 65 | + token -> Repo.delete(token) | |
| 66 | + end | |
| 64 | 67 | end |
| 65 | 68 | |
| 66 | 69 | @doc """ |
modified
lib/git_gud_web/components/audit_components.ex
+51
−0
@@ -100,6 +100,42 @@ defmodule GitGudWeb.AuditComponents do
| 100 | 100 | "repo.interaction_policy_changed" -> |
| 101 | 101 | "changed the interaction policy #{from_to(entry.metadata)}" |
| 102 | 102 | |
| 103 | + "branch_protection.created" -> | |
| 104 | + "protected branches matching" | |
| 105 | + | |
| 106 | + "branch_protection.deleted" -> | |
| 107 | + "removed the protection on" | |
| 108 | + | |
| 109 | + "deploy_key.added" -> | |
| 110 | + deploy_key_added(entry.metadata) | |
| 111 | + | |
| 112 | + "deploy_key.removed" -> | |
| 113 | + "removed the deploy key" | |
| 114 | + | |
| 115 | + "webhook.created" -> | |
| 116 | + "added a webhook to" | |
| 117 | + | |
| 118 | + "webhook.deleted" -> | |
| 119 | + "deleted the webhook to" | |
| 120 | + | |
| 121 | + "webhook.enabled" -> | |
| 122 | + "enabled the webhook to" | |
| 123 | + | |
| 124 | + "webhook.disabled" -> | |
| 125 | + "disabled the webhook to" | |
| 126 | + | |
| 127 | + "registry_token.created" -> | |
| 128 | + "created the registry token (#{joined(entry.metadata["scopes"])})" | |
| 129 | + | |
| 130 | + "registry_token.revoked" -> | |
| 131 | + "revoked the registry token" | |
| 132 | + | |
| 133 | + "job_token_grant.created" -> | |
| 134 | + grant_created(entry.metadata) | |
| 135 | + | |
| 136 | + "job_token_grant.deleted" -> | |
| 137 | + "removed job-token access for" | |
| 138 | + | |
| 103 | 139 | "audit.pruned" -> |
| 104 | 140 | "pruned #{entry.metadata["count"]} audit entries" |
| 105 | 141 |
@@ -128,6 +164,21 @@ defmodule GitGudWeb.AuditComponents do
| 128 | 164 | "set upstream sync to #{metadata["mode"]}#{auto}" |
| 129 | 165 | end |
| 130 | 166 | |
| 167 | + defp deploy_key_added(%{"read_write" => true}), do: "added the read-write deploy key" | |
| 168 | + defp deploy_key_added(_), do: "added the read-only deploy key" | |
| 169 | + | |
| 170 | + defp grant_created(metadata) do | |
| 171 | + can = | |
| 172 | + [metadata["read"] && "read", metadata["dispatch"] && "dispatch"] | |
| 173 | + |> Enum.filter(& &1) | |
| 174 | + |> Enum.join(", ") | |
| 175 | + | |
| 176 | + "granted job-token access (#{can}) to" | |
| 177 | + end | |
| 178 | + | |
| 179 | + defp joined(values) when is_list(values), do: Enum.join(values, ", ") | |
| 180 | + defp joined(value), do: to_string(value) | |
| 181 | + | |
| 131 | 182 | defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ") |
| 132 | 183 | |
| 133 | 184 | defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})" |
modified
lib/git_gud_web/live/branch_protection_live/index.ex
+35
−2
@@ -75,7 +75,31 @@ defmodule GitGudWeb.BranchProtectionLive.Index do
| 75 | 75 | def handle_event(_event, _params, socket), do: {:noreply, socket} |
| 76 | 76 | |
| 77 | 77 | defp handle_admin_event("add", %{"branch_protection" => attrs}, socket) when is_map(attrs) do |
| 78 | − case BranchProtections.create_rule(socket.assigns.repo, parse_checks(attrs)) do | |
| 78 | + repo = socket.assigns.repo | |
| 79 | + | |
| 80 | + audited = | |
| 81 | + GitGud.Audit.transact( | |
| 82 | + socket.assigns.current_scope, | |
| 83 | + "branch_protection.created", | |
| 84 | + fn -> BranchProtections.create_rule(repo, parse_checks(attrs)) end, | |
| 85 | + fn rule -> | |
| 86 | + %{ | |
| 87 | + repository: repo, | |
| 88 | + target_id: rule.id, | |
| 89 | + target_label: rule.pattern, | |
| 90 | + metadata: | |
| 91 | + Map.take(rule, [ | |
| 92 | + :required_approvals, | |
| 93 | + :allow_force_push, | |
| 94 | + :allow_deletions, | |
| 95 | + :require_status_checks, | |
| 96 | + :required_checks | |
| 97 | + ]) | |
| 98 | + } | |
| 99 | + end | |
| 100 | + ) | |
| 101 | + | |
| 102 | + case audited do | |
| 79 | 103 | {:ok, rule} -> |
| 80 | 104 | {:noreply, |
| 81 | 105 | socket |
@@ -95,7 +119,16 @@ defmodule GitGudWeb.BranchProtectionLive.Index do
| 95 | 119 | {:noreply, socket} |
| 96 | 120 | |
| 97 | 121 | rule -> |
| 98 | − {:ok, _} = BranchProtections.delete_rule(rule) | |
| 122 | + {:ok, _} = | |
| 123 | + GitGud.Audit.transact( | |
| 124 | + socket.assigns.current_scope, | |
| 125 | + "branch_protection.deleted", | |
| 126 | + fn -> BranchProtections.delete_rule(rule) end, | |
| 127 | + fn _ -> | |
| 128 | + %{repository: socket.assigns.repo, target_id: rule.id, target_label: rule.pattern} | |
| 129 | + end | |
| 130 | + ) | |
| 131 | + | |
| 99 | 132 | {:noreply, socket |> stream_delete(:rules, rule) |> put_flash(:info, "Rule removed.")} |
| 100 | 133 | end |
| 101 | 134 | end |
modified
lib/git_gud_web/live/deploy_key_live/index.ex
+33
−2
@@ -43,7 +43,24 @@ defmodule GitGudWeb.DeployKeyLive.Index do
| 43 | 43 | def handle_event(_event, _params, socket), do: {:noreply, socket} |
| 44 | 44 | |
| 45 | 45 | defp handle_admin_event("add", %{"deploy_key" => attrs}, socket) when is_map(attrs) do |
| 46 | − case DeployKeys.create_key(socket.assigns.repo, attrs) do | |
| 46 | + repo = socket.assigns.repo | |
| 47 | + | |
| 48 | + audited = | |
| 49 | + GitGud.Audit.transact( | |
| 50 | + socket.assigns.current_scope, | |
| 51 | + "deploy_key.added", | |
| 52 | + fn -> DeployKeys.create_key(repo, attrs) end, | |
| 53 | + fn key -> | |
| 54 | + %{ | |
| 55 | + repository: repo, | |
| 56 | + target_id: key.id, | |
| 57 | + target_label: key.title, | |
| 58 | + metadata: %{fingerprint: key.fingerprint, read_write: key.read_write} | |
| 59 | + } | |
| 60 | + end | |
| 61 | + ) | |
| 62 | + | |
| 63 | + case audited do | |
| 47 | 64 | {:ok, key} -> |
| 48 | 65 | {:noreply, |
| 49 | 66 | socket |
@@ -63,7 +80,21 @@ defmodule GitGudWeb.DeployKeyLive.Index do
| 63 | 80 | {:noreply, socket} |
| 64 | 81 | |
| 65 | 82 | key -> |
| 66 | − {:ok, _} = DeployKeys.delete_key(key) | |
| 83 | + {:ok, _} = | |
| 84 | + GitGud.Audit.transact( | |
| 85 | + socket.assigns.current_scope, | |
| 86 | + "deploy_key.removed", | |
| 87 | + fn -> DeployKeys.delete_key(key) end, | |
| 88 | + fn _ -> | |
| 89 | + %{ | |
| 90 | + repository: socket.assigns.repo, | |
| 91 | + target_id: key.id, | |
| 92 | + target_label: key.title, | |
| 93 | + metadata: %{fingerprint: key.fingerprint} | |
| 94 | + } | |
| 95 | + end | |
| 96 | + ) | |
| 97 | + | |
| 67 | 98 | {:noreply, socket |> stream_delete(:keys, key) |> put_flash(:info, "Removed.")} |
| 68 | 99 | end |
| 69 | 100 | end |
modified
lib/git_gud_web/live/job_token_live/index.ex
+38
−3
@@ -25,6 +25,14 @@ defmodule GitGudWeb.JobTokenLive.Index do
| 25 | 25 | |> assign(:page_title, "Job token access — #{owner}/#{name}")} |
| 26 | 26 | end |
| 27 | 27 | |
| 28 | + # Which repository or organization the grant lets in, as the page | |
| 29 | + # shows it. | |
| 30 | + defp grant_label(grant) do | |
| 31 | + grant | |
| 32 | + |> GitGud.Repo.preload(source_repository: [:owner, :organization], source_organization: []) | |
| 33 | + |> JobTokenGrants.source_label() | |
| 34 | + end | |
| 35 | + | |
| 28 | 36 | defp load(socket), do: assign(socket, :grants, JobTokenGrants.list(socket.assigns.repo)) |
| 29 | 37 | |
| 30 | 38 | @impl true |
@@ -46,7 +54,24 @@ defmodule GitGudWeb.JobTokenLive.Index do
| 46 | 54 | put_flash(socket, :error, "Name a repository (owner/name) or an organization.")} |
| 47 | 55 | |
| 48 | 56 | true -> |
| 49 | − case JobTokenGrants.create(socket.assigns.repo, source, attrs) do | |
| 57 | + repo = socket.assigns.repo | |
| 58 | + | |
| 59 | + audited = | |
| 60 | + GitGud.Audit.transact( | |
| 61 | + socket.assigns.current_scope, | |
| 62 | + "job_token_grant.created", | |
| 63 | + fn -> JobTokenGrants.create(repo, source, attrs) end, | |
| 64 | + fn grant -> | |
| 65 | + %{ | |
| 66 | + repository: repo, | |
| 67 | + target_id: grant.id, | |
| 68 | + target_label: grant_label(grant), | |
| 69 | + metadata: %{read: grant.read, dispatch: grant.dispatch} | |
| 70 | + } | |
| 71 | + end | |
| 72 | + ) | |
| 73 | + | |
| 74 | + case audited do | |
| 50 | 75 | {:ok, _grant} -> |
| 51 | 76 | {:noreply, socket |> load() |> put_flash(:info, "Granted #{source}.")} |
| 52 | 77 |
@@ -62,8 +87,18 @@ defmodule GitGudWeb.JobTokenLive.Index do
| 62 | 87 | |
| 63 | 88 | def handle_event("delete_grant", %{"id" => id}, socket) do |
| 64 | 89 | if GitGudWeb.RepoAdmin.admin_now?(socket) do |
| 65 | − if id = GitGudWeb.RepoAdmin.parse_id(id), | |
| 66 | − do: _ = JobTokenGrants.delete(socket.assigns.repo, id) | |
| 90 | + repo = socket.assigns.repo | |
| 91 | + | |
| 92 | + if id = GitGudWeb.RepoAdmin.parse_id(id) do | |
| 93 | + GitGud.Audit.transact( | |
| 94 | + socket.assigns.current_scope, | |
| 95 | + "job_token_grant.deleted", | |
| 96 | + fn -> JobTokenGrants.delete(repo, id) end, | |
| 97 | + fn grant -> | |
| 98 | + %{repository: repo, target_id: grant.id, target_label: grant_label(grant)} | |
| 99 | + end | |
| 100 | + ) | |
| 101 | + end | |
| 67 | 102 | |
| 68 | 103 | {:noreply, socket |> load() |> put_flash(:info, "Grant removed.")} |
| 69 | 104 | else |
modified
lib/git_gud_web/live/registry_token_live/index.ex
+28
−3
@@ -59,7 +59,24 @@ defmodule GitGudWeb.RegistryTokenLive.Index do
| 59 | 59 | name = String.trim(params["name"]) |
| 60 | 60 | scopes = params["scopes"] |
| 61 | 61 | |
| 62 | − case Packages.create_registry_token(socket.assigns.repo, name, scopes) do | |
| 62 | + repo = socket.assigns.repo | |
| 63 | + | |
| 64 | + audited = | |
| 65 | + GitGud.Audit.transact( | |
| 66 | + socket.assigns.current_scope, | |
| 67 | + "registry_token.created", | |
| 68 | + fn -> Packages.create_registry_token(repo, name, scopes) end, | |
| 69 | + fn token -> | |
| 70 | + %{ | |
| 71 | + repository: repo, | |
| 72 | + target_id: token.id, | |
| 73 | + target_label: token.name, | |
| 74 | + metadata: %{scopes: token.scopes} | |
| 75 | + } | |
| 76 | + end | |
| 77 | + ) | |
| 78 | + | |
| 79 | + case audited do | |
| 63 | 80 | {:ok, _token, plaintext} -> |
| 64 | 81 | {:noreply, |
| 65 | 82 | socket |
@@ -84,8 +101,16 @@ defmodule GitGudWeb.RegistryTokenLive.Index do
| 84 | 101 | |
| 85 | 102 | def handle_event("delete_token", %{"id" => id}, socket) do |
| 86 | 103 | if RepoAdmin.admin_now?(socket) do |
| 87 | − if id = RepoAdmin.parse_id(id), | |
| 88 | − do: :ok = Packages.delete_registry_token(socket.assigns.repo, id) | |
| 104 | + repo = socket.assigns.repo | |
| 105 | + | |
| 106 | + if id = RepoAdmin.parse_id(id) do | |
| 107 | + GitGud.Audit.transact( | |
| 108 | + socket.assigns.current_scope, | |
| 109 | + "registry_token.revoked", | |
| 110 | + fn -> Packages.delete_registry_token(repo, id) end, | |
| 111 | + fn token -> %{repository: repo, target_id: token.id, target_label: token.name} end | |
| 112 | + ) | |
| 113 | + end | |
| 89 | 114 | |
| 90 | 115 | {:noreply, |
| 91 | 116 | socket |> load() |> assign(:new_token, nil) |> put_flash(:info, "Token revoked.")} |
modified
lib/git_gud_web/live/webhook_live/index.ex
+43
−3
@@ -58,7 +58,21 @@ defmodule GitGudWeb.WebhookLive.Index do
| 58 | 58 | events = attrs |> Map.get("events", []) |> List.wrap() |
| 59 | 59 | attrs = Map.put(attrs, "events", events) |
| 60 | 60 | |
| 61 | − case Webhooks.create_webhook(socket.assigns.repo, attrs) do | |
| 61 | + audited = | |
| 62 | + GitGud.Audit.transact( | |
| 63 | + socket.assigns.current_scope, | |
| 64 | + "webhook.created", | |
| 65 | + fn -> Webhooks.create_webhook(socket.assigns.repo, attrs) end, | |
| 66 | + fn hook -> | |
| 67 | + hook_attrs(socket, hook, %{ | |
| 68 | + events: hook.events, | |
| 69 | + content_type: hook.content_type, | |
| 70 | + insecure_ssl: hook.insecure_ssl | |
| 71 | + }) | |
| 72 | + end | |
| 73 | + ) | |
| 74 | + | |
| 75 | + case audited do | |
| 62 | 76 | {:ok, hook} -> |
| 63 | 77 | {:noreply, |
| 64 | 78 | socket |
@@ -80,7 +94,13 @@ defmodule GitGudWeb.WebhookLive.Index do
| 80 | 94 | {:noreply, socket} |
| 81 | 95 | |
| 82 | 96 | hook -> |
| 83 | − {:ok, _} = Webhooks.delete_webhook(hook) | |
| 97 | + {:ok, _} = | |
| 98 | + GitGud.Audit.transact( | |
| 99 | + socket.assigns.current_scope, | |
| 100 | + "webhook.deleted", | |
| 101 | + fn -> Webhooks.delete_webhook(hook) end, | |
| 102 | + fn _ -> hook_attrs(socket, hook, %{}) end | |
| 103 | + ) | |
| 84 | 104 | |
| 85 | 105 | {:noreply, |
| 86 | 106 | socket |
@@ -96,13 +116,33 @@ defmodule GitGudWeb.WebhookLive.Index do
| 96 | 116 | {:noreply, socket} |
| 97 | 117 | |
| 98 | 118 | hook -> |
| 99 | − {:ok, updated} = Webhooks.update_webhook(hook, %{"enabled" => !hook.enabled}) | |
| 119 | + {:ok, updated} = toggle(socket, hook) | |
| 100 | 120 | {:noreply, stream_insert(socket, :hooks, updated)} |
| 101 | 121 | end |
| 102 | 122 | end |
| 103 | 123 | |
| 104 | 124 | defp handle_admin_event(_event, _params, socket), do: {:noreply, socket} |
| 105 | 125 | |
| 126 | + defp toggle(socket, hook) do | |
| 127 | + GitGud.Audit.transact( | |
| 128 | + socket.assigns.current_scope, | |
| 129 | + if(hook.enabled, do: "webhook.disabled", else: "webhook.enabled"), | |
| 130 | + fn -> Webhooks.update_webhook(hook, %{"enabled" => !hook.enabled}) end, | |
| 131 | + fn _ -> hook_attrs(socket, hook, %{}) end | |
| 132 | + ) | |
| 133 | + end | |
| 134 | + | |
| 135 | + # The URL's host and nothing else: the path of a chat service's hook | |
| 136 | + # URL is its credential. | |
| 137 | + defp hook_attrs(socket, hook, metadata) do | |
| 138 | + %{ | |
| 139 | + repository: socket.assigns.repo, | |
| 140 | + target_id: hook.id, | |
| 141 | + target_label: URI.parse(hook.url).host || "(no host)", | |
| 142 | + metadata: metadata | |
| 143 | + } | |
| 144 | + end | |
| 145 | + | |
| 106 | 146 | @impl true |
| 107 | 147 | def render(assigns) do |
| 108 | 148 | ~H""" |
modified
lib/git_gud_web/live/webhook_live/org.ex
+44
−3
@@ -69,7 +69,21 @@ defmodule GitGudWeb.WebhookLive.Org do
| 69 | 69 | events = attrs |> Map.get("events", []) |> List.wrap() |
| 70 | 70 | attrs = Map.put(attrs, "events", events) |
| 71 | 71 | |
| 72 | − case Webhooks.create_webhook(socket.assigns.org, attrs) do | |
| 72 | + audited = | |
| 73 | + GitGud.Audit.transact( | |
| 74 | + socket.assigns.current_scope, | |
| 75 | + "webhook.created", | |
| 76 | + fn -> Webhooks.create_webhook(socket.assigns.org, attrs) end, | |
| 77 | + fn hook -> | |
| 78 | + hook_attrs(socket, hook, %{ | |
| 79 | + events: hook.events, | |
| 80 | + content_type: hook.content_type, | |
| 81 | + insecure_ssl: hook.insecure_ssl | |
| 82 | + }) | |
| 83 | + end | |
| 84 | + ) | |
| 85 | + | |
| 86 | + case audited do | |
| 73 | 87 | {:ok, _hook} -> |
| 74 | 88 | {:noreply, |
| 75 | 89 | socket |
@@ -88,7 +102,14 @@ defmodule GitGudWeb.WebhookLive.Org do
| 88 | 102 | {:noreply, socket} |
| 89 | 103 | |
| 90 | 104 | hook -> |
| 91 | − {:ok, _} = Webhooks.delete_webhook(hook) | |
| 105 | + {:ok, _} = | |
| 106 | + GitGud.Audit.transact( | |
| 107 | + socket.assigns.current_scope, | |
| 108 | + "webhook.deleted", | |
| 109 | + fn -> Webhooks.delete_webhook(hook) end, | |
| 110 | + fn _ -> hook_attrs(socket, hook, %{}) end | |
| 111 | + ) | |
| 112 | + | |
| 92 | 113 | {:noreply, socket |> load_hooks() |> put_flash(:info, "Webhook removed.")} |
| 93 | 114 | end |
| 94 | 115 | end |
@@ -99,13 +120,33 @@ defmodule GitGudWeb.WebhookLive.Org do
| 99 | 120 | {:noreply, socket} |
| 100 | 121 | |
| 101 | 122 | hook -> |
| 102 | − {:ok, _} = Webhooks.update_webhook(hook, %{"enabled" => !hook.enabled}) | |
| 123 | + {:ok, _} = toggle(socket, hook) | |
| 103 | 124 | {:noreply, load_hooks(socket)} |
| 104 | 125 | end |
| 105 | 126 | end |
| 106 | 127 | |
| 107 | 128 | defp handle_admin_event(_event, _params, socket), do: {:noreply, socket} |
| 108 | 129 | |
| 130 | + defp toggle(socket, hook) do | |
| 131 | + GitGud.Audit.transact( | |
| 132 | + socket.assigns.current_scope, | |
| 133 | + if(hook.enabled, do: "webhook.disabled", else: "webhook.enabled"), | |
| 134 | + fn -> Webhooks.update_webhook(hook, %{"enabled" => !hook.enabled}) end, | |
| 135 | + fn _ -> hook_attrs(socket, hook, %{}) end | |
| 136 | + ) | |
| 137 | + end | |
| 138 | + | |
| 139 | + # The URL's host and nothing else: the path of a chat service's hook | |
| 140 | + # URL is its credential. | |
| 141 | + defp hook_attrs(socket, hook, metadata) do | |
| 142 | + %{ | |
| 143 | + organization: socket.assigns.org, | |
| 144 | + target_id: hook.id, | |
| 145 | + target_label: URI.parse(hook.url).host || "(no host)", | |
| 146 | + metadata: metadata | |
| 147 | + } | |
| 148 | + end | |
| 149 | + | |
| 109 | 150 | @impl true |
| 110 | 151 | def render(assigns) do |
| 111 | 152 | ~H""" |
added
test/git_gud_web/live/repo_controls_audit_test.exs
+175
−0
@@ -0,0 +1,175 @@
| 1 | +defmodule GitGudWeb.RepoControlsAuditTest do | |
| 2 | + @moduledoc """ | |
| 3 | + A repository's controls leave audit entries: branch protection, deploy | |
| 4 | + keys, webhooks (the repository's and the org's), registry tokens and | |
| 5 | + job-token grants. Never a key body, a token, a webhook secret or the | |
| 6 | + path of a webhook URL. | |
| 7 | + """ | |
| 8 | + use GitGudWeb.ConnCase, async: true | |
| 9 | + | |
| 10 | + import Ecto.Query | |
| 11 | + import Phoenix.LiveViewTest | |
| 12 | + import GitGud.AccountsFixtures | |
| 13 | + import GitGud.ForgeFixtures | |
| 14 | + | |
| 15 | + alias GitGud.Audit.Entry | |
| 16 | + alias GitGud.Organizations | |
| 17 | + alias GitGud.Repositories.Storage | |
| 18 | + | |
| 19 | + @key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIvCK0qd2v15O96oXcAYO3FpyDC2OYa3LxOoehl0HUx ci" | |
| 20 | + | |
| 21 | + setup %{conn: conn} do | |
| 22 | + {owner, repo} = repository_fixture() | |
| 23 | + repo = GitGud.Repo.preload(repo, [:owner, :organization]) | |
| 24 | + | |
| 25 | + {:ok, | |
| 26 | + owner: owner, | |
| 27 | + repo: repo, | |
| 28 | + conn: log_in_user(conn, owner), | |
| 29 | + base: "/r/#{Storage.repo_handle(repo)}/#{repo.name}/settings"} | |
| 30 | + end | |
| 31 | + | |
| 32 | + defp entries(repo), | |
| 33 | + do: GitGud.Repo.all(from e in Entry, where: e.repository_id == ^repo.id, order_by: e.id) | |
| 34 | + | |
| 35 | + defp stored, do: GitGud.Repo.all(Entry) |> Enum.map(&Map.from_struct/1) |> inspect() | |
| 36 | + | |
| 37 | + test "branch protection", %{conn: conn, repo: repo, base: base} do | |
| 38 | + {:ok, lv, _} = live(conn, base <> "/branches") | |
| 39 | + | |
| 40 | + lv | |
| 41 | + |> form("#bp-form", %{ | |
| 42 | + "branch_protection" => %{"pattern" => "release/*", "required_approvals" => "2"} | |
| 43 | + }) | |
| 44 | + |> render_submit() | |
| 45 | + | |
| 46 | + [rule] = GitGud.BranchProtections.list_rules(repo) | |
| 47 | + lv |> element("#delete-rule-#{rule.id}") |> render_click() | |
| 48 | + | |
| 49 | + assert [added, deleted] = entries(repo) | |
| 50 | + assert {added.action, added.target_label} == {"branch_protection.created", "release/*"} | |
| 51 | + assert added.metadata["required_approvals"] == 2 | |
| 52 | + assert {deleted.action, deleted.target_label} == {"branch_protection.deleted", "release/*"} | |
| 53 | + end | |
| 54 | + | |
| 55 | + test "deploy keys, by title and fingerprint", %{conn: conn, repo: repo, base: base} do | |
| 56 | + {:ok, lv, _} = live(conn, base <> "/deploy-keys") | |
| 57 | + | |
| 58 | + lv | |
| 59 | + |> form("#deploy-key-form", %{ | |
| 60 | + "deploy_key" => %{"title" => "ci", "key" => @key, "read_write" => "true"} | |
| 61 | + }) | |
| 62 | + |> render_submit() | |
| 63 | + | |
| 64 | + [key] = GitGud.DeployKeys.list_keys(repo) | |
| 65 | + lv |> element("#delete-deploy-key-#{key.id}") |> render_click() | |
| 66 | + | |
| 67 | + assert [added, removed] = entries(repo) | |
| 68 | + assert added.action == "deploy_key.added" | |
| 69 | + assert added.metadata == %{"fingerprint" => key.fingerprint, "read_write" => true} | |
| 70 | + assert {removed.action, removed.target_label} == {"deploy_key.removed", "ci"} | |
| 71 | + refute stored() =~ "AAAAC3NzaC1lZDI1NTE5AAAAIDIvCK0q" | |
| 72 | + end | |
| 73 | + | |
| 74 | + describe "webhooks" do | |
| 75 | + test "a repository's: the host, never the path or the secret", %{ | |
| 76 | + conn: conn, | |
| 77 | + repo: repo, | |
| 78 | + base: base | |
| 79 | + } do | |
| 80 | + {:ok, lv, _} = live(conn, base <> "/webhooks") | |
| 81 | + | |
| 82 | + lv | |
| 83 | + |> form("#webhook-form", %{ | |
| 84 | + "webhook" => %{"url" => "https://hooks.example/T000/B000/XYZTOKEN"} | |
| 85 | + }) | |
| 86 | + |> render_submit() | |
| 87 | + | |
| 88 | + [hook] = GitGud.Webhooks.list_webhooks(repo) | |
| 89 | + lv |> element("#toggle-webhook-#{hook.id}") |> render_click() | |
| 90 | + lv |> element("#toggle-webhook-#{hook.id}") |> render_click() | |
| 91 | + lv |> element("#delete-webhook-#{hook.id}") |> render_click() | |
| 92 | + | |
| 93 | + assert ~w(webhook.created webhook.disabled webhook.enabled webhook.deleted) == | |
| 94 | + Enum.map(entries(repo), & &1.action) | |
| 95 | + | |
| 96 | + assert Enum.all?(entries(repo), &(&1.target_label == "hooks.example")) | |
| 97 | + refute stored() =~ "XYZTOKEN" | |
| 98 | + refute stored() =~ Base.encode64(hook.secret) | |
| 99 | + end | |
| 100 | + | |
| 101 | + test "an org's", %{conn: conn, owner: owner} do | |
| 102 | + handle = "wh#{System.unique_integer([:positive])}" | |
| 103 | + | |
| 104 | + {:ok, org} = | |
| 105 | + Organizations.create_organization(owner, %{"handle" => handle, "name" => handle}) | |
| 106 | + | |
| 107 | + {:ok, lv, _} = live(conn, ~p"/orgs/#{handle}/settings/webhooks") | |
| 108 | + | |
| 109 | + lv | |
| 110 | + |> form("#webhook-form", %{"webhook" => %{"url" => "https://ci.example/hook/abc"}}) | |
| 111 | + |> render_submit() | |
| 112 | + | |
| 113 | + assert [%Entry{action: "webhook.created", organization_id: oid, target_label: host}] = | |
| 114 | + GitGud.Repo.all(from e in Entry, where: e.organization_id == ^org.id) | |
| 115 | + | |
| 116 | + assert {oid, host} == {org.id, "ci.example"} | |
| 117 | + end | |
| 118 | + end | |
| 119 | + | |
| 120 | + test "registry tokens, never the token", %{conn: conn, repo: repo, base: base} do | |
| 121 | + {:ok, lv, _} = live(conn, base <> "/registry-tokens") | |
| 122 | + | |
| 123 | + html = | |
| 124 | + lv | |
| 125 | + |> form("form[phx-submit=create_token]", %{"name" => "argo-pull", "scopes" => ["pull"]}) | |
| 126 | + |> render_submit() | |
| 127 | + | |
| 128 | + [plaintext] = Regex.run(~r/ggrt_[A-Za-z0-9_-]+/, html) | |
| 129 | + [token] = GitGud.Packages.list_registry_tokens(repo) | |
| 130 | + render_click(lv, "delete_token", %{"id" => to_string(token.id)}) | |
| 131 | + # Already gone: nothing more to record. | |
| 132 | + render_click(lv, "delete_token", %{"id" => to_string(token.id)}) | |
| 133 | + | |
| 134 | + assert [created, revoked] = entries(repo) | |
| 135 | + assert {created.target_label, created.metadata} == {"argo-pull", %{"scopes" => ["pull"]}} | |
| 136 | + assert revoked.action == "registry_token.revoked" | |
| 137 | + refute stored() =~ plaintext | |
| 138 | + end | |
| 139 | + | |
| 140 | + test "job-token grants", %{conn: conn, repo: repo, base: base} do | |
| 141 | + {_, source} = repository_fixture() | |
| 142 | + source = GitGud.Repo.preload(source, [:owner, :organization]) | |
| 143 | + label = "#{Storage.repo_handle(source)}/#{source.name}" | |
| 144 | + {:ok, lv, _} = live(conn, base <> "/job-tokens") | |
| 145 | + | |
| 146 | + lv | |
| 147 | + |> form("form[phx-submit=create_grant]", %{"source" => label, "read" => "true"}) | |
| 148 | + |> render_submit() | |
| 149 | + | |
| 150 | + [grant] = GitGud.Workflows.JobTokenGrants.list(repo) | |
| 151 | + lv |> element("button[phx-value-id='#{grant.id}']") |> render_click() | |
| 152 | + | |
| 153 | + assert [created, deleted] = entries(repo) | |
| 154 | + assert created.target_label == label | |
| 155 | + assert created.metadata == %{"read" => true, "dispatch" => false} | |
| 156 | + assert {deleted.action, deleted.target_label} == {"job_token_grant.deleted", label} | |
| 157 | + end | |
| 158 | + | |
| 159 | + test "someone who isn't an admin changes nothing and leaves nothing", %{owner: owner} do | |
| 160 | + {:ok, repo} = | |
| 161 | + GitGud.Repositories.create_repository(owner, %{"name" => "open", "visibility" => "public"}) | |
| 162 | + | |
| 163 | + {source_owner, source} = repository_fixture() | |
| 164 | + conn = log_in_user(build_conn(), user_fixture()) | |
| 165 | + {:ok, lv, _} = live(conn, "/r/#{owner.handle}/open/settings/job-tokens") | |
| 166 | + | |
| 167 | + render_submit(lv, "create_grant", %{ | |
| 168 | + "source" => "#{Storage.owner_handle(source_owner)}/#{source.name}", | |
| 169 | + "read" => "true" | |
| 170 | + }) | |
| 171 | + | |
| 172 | + assert GitGud.Workflows.JobTokenGrants.list(repo) == [] | |
| 173 | + assert entries(repo) == [] | |
| 174 | + end | |
| 175 | +end |
Parents: ec2ea47