Audit: branch protection, deploy keys, webhooks, registry tokens, job-token grants

99ed362 · Gabriel Morell · 2026-10-22 16:02

24 files +500 -18
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