Audit: runners and fork-run approvals

5db6178 · Gabriel Morell · 2026-10-22 16:34

35 files +317 -26
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/audit.ex
+20 −0
@@ -224,6 +224,26 @@ defmodule GitGud.Audit do
224 224 }
225 225 end
226 226
227 + @doc """
228 + Record approving or rejecting a pull request's fork runs, once it's
229 + done and only if there were runs. After the fact rather than in a
230 + transaction: each run is approved or cancelled in its own, and
231 + runners are told as each one commits.
232 + """
233 + @spec fork_runs(actor(), :approved | :rejected, struct(), list()) :: Entry.t() | nil
234 + def fork_runs(_actor, _verdict, _pr, []), do: nil
235 +
236 + def fork_runs(actor, verdict, pr, runs) do
237 + repo = Repo.get!(Repository, pr.repository_id)
238 +
239 + record(actor, "ci.fork_runs_#{verdict}", %{
240 + repository: repo,
241 + target_id: pr.id,
242 + target_label: "##{pr.number}",
243 + metadata: %{pull_request: pr.number, runs: length(runs)}
244 + })
245 + end
246 +
227 247 # ── reading ──────────────────────────────────────────────────────────
228 248
229 249 @default_limit 50
modified lib/git_gud/audit/actor.ex
+6 −1
@@ -34,7 +34,12 @@ defmodule GitGud.Audit.Actor do
34 34 def job(%{id: job_id, job_id: name}, %{number: number}),
35 35 do: %__MODULE__{type: "job", ref: job_id, label: "run ##{number} / job #{name}", via: "job"}
36 36
37 − @doc "The forge itself: a cron job, an import, an incoming webhook."
37 + @doc """
38 + The forge itself: a cron job, an import, an incoming webhook — or an
39 + operator at the console, running a mix task or release command.
40 + """
41 + def system(:console), do: %__MODULE__{type: "system", label: "console"}
42 +
38 43 def system(kind) when kind in [:cron, :import, :webhook],
39 44 do: %__MODULE__{
40 45 type: "system",
modified lib/git_gud/audit/catalog.ex
+21 −0
@@ -127,6 +127,27 @@ defmodule GitGud.Audit.Catalog do
127 127 target: "job_token_grant",
128 128 keys: ~w()
129 129 },
130 + # Runners: never a registration token or a runner's own token.
131 + "runner.registration_token_minted" => %{
132 + category: "runners",
133 + target: nil,
134 + keys: ~w(expires_at)
135 + },
136 + "runner.registered" => %{category: "runners", target: "runner", keys: ~w(labels version)},
137 + "runner.created" => %{category: "runners", target: "runner", keys: ~w(labels)},
138 + "runner.deleted" => %{category: "runners", target: "runner", keys: ~w()},
139 + # Approving a fork's runs lets code nobody here wrote run on the
140 + # repository's runners.
141 + "ci.fork_runs_approved" => %{
142 + category: "ci",
143 + target: "pull_request",
144 + keys: ~w(pull_request runs)
145 + },
146 + "ci.fork_runs_rejected" => %{
147 + category: "ci",
148 + target: "pull_request",
149 + keys: ~w(pull_request runs)
150 + },
130 151 # The pruner's own trace: how much went, from before when.
131 152 "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)}
132 153 }
modified lib/git_gud/workflows/runners.ex
+37 −5
@@ -87,21 +87,44 @@ defmodule GitGud.Workflows.Runners do
87 87 Redeem `token` and register a runner with `attrs` in its scope, all or
88 88 nothing. `{:ok, runner, bearer}`, `:error` for a bad token, or
89 89 `{:error, changeset}`.
90 +
91 + `audit: ip` records `runner.registered` in the same transaction, with
92 + the new runner as the actor and `ip` as its address. Here rather than
93 + at the call site: the actor doesn't exist until the runner does, and
94 + only this transaction knows which scope the token was for.
90 95 """
91 − def register_with_token(token, attrs) do
96 + def register_with_token(token, attrs, opts \\ []) do
92 97 Repo.transact(fn ->
93 98 with {:ok, scope} <- consume_registration_token(token),
94 99 {:ok, runner, bearer} <- register(attrs, scope) do
95 − {:ok, {runner, bearer}}
100 + entry =
101 + if Keyword.has_key?(opts, :audit) do
102 + GitGud.Audit.insert!(
103 + GitGud.Audit.Actor.runner(runner, opts[:audit]),
104 + "runner.registered",
105 + Map.merge(audit_scope(scope), %{
106 + target_id: runner.id,
107 + metadata: %{labels: runner_labels(runner), version: runner.version}
108 + })
109 + )
110 + end
111 +
112 + {:ok, {runner, bearer, entry}}
96 113 else
97 114 :error -> {:error, :bad_token}
98 115 {:error, _} = err -> err
99 116 end
100 117 end)
101 118 |> case do
102 − {:ok, {runner, bearer}} -> {:ok, runner, bearer}
103 − {:error, :bad_token} -> :error
104 − {:error, _} = err -> err
119 + {:ok, {runner, bearer, entry}} ->
120 + if entry, do: GitGud.Audit.notice(entry)
121 + {:ok, runner, bearer}
122 +
123 + {:error, :bad_token} ->
124 + :error
125 +
126 + {:error, _} = err ->
127 + err
105 128 end
106 129 end
107 130
@@ -203,6 +226,15 @@ defmodule GitGud.Workflows.Runners do
203 226 end
204 227 end
205 228
229 + @doc "Where a runner of `scope` belongs in the audit log."
230 + def audit_scope(%Repository{} = repo), do: %{repository: repo}
231 + def audit_scope(%Organization{} = org), do: %{organization: org}
232 + def audit_scope(_global), do: %{}
233 +
234 + @doc "A runner's advertised labels, as a list."
235 + def runner_labels(%Runner{labels: %{"labels" => labels}}) when is_list(labels), do: labels
236 + def runner_labels(_), do: []
237 +
206 238 defp scope_attrs(:global), do: %{}
207 239 defp scope_attrs(%Repository{id: id}), do: %{repository_id: id}
208 240 defp scope_attrs(%Organization{id: id}), do: %{organization_id: id}
modified lib/git_gud_web/components/audit_components.ex
+21 −0
@@ -136,6 +136,24 @@ defmodule GitGudWeb.AuditComponents do
136 136 "job_token_grant.deleted" ->
137 137 "removed job-token access for"
138 138
139 + "runner.registration_token_minted" ->
140 + "minted a runner registration token"
141 +
142 + "runner.registered" ->
143 + "registered"
144 +
145 + "runner.created" ->
146 + "created the runner"
147 +
148 + "runner.deleted" ->
149 + "deleted the runner"
150 +
151 + "ci.fork_runs_approved" ->
152 + fork_runs("approved", entry.metadata)
153 +
154 + "ci.fork_runs_rejected" ->
155 + fork_runs("rejected", entry.metadata)
156 +
139 157 "audit.pruned" ->
140 158 "pruned #{entry.metadata["count"]} audit entries"
141 159
@@ -179,6 +197,9 @@ defmodule GitGudWeb.AuditComponents do
179 197 defp joined(values) when is_list(values), do: Enum.join(values, ", ")
180 198 defp joined(value), do: to_string(value)
181 199
200 + defp fork_runs(verb, metadata),
201 + do: "#{verb} #{metadata["runs"]} fork run(s) on pull request ##{metadata["pull_request"]}"
202 +
182 203 defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ")
183 204
184 205 defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})"
modified lib/git_gud_web/controllers/api/mobile/pull_request_controller.ex
+12 −3

Click to load diff…

modified lib/git_gud_web/controllers/runner_controller.ex
+1 −1

Click to load diff…

modified lib/git_gud_web/live/org_live/settings_runners.ex
+41 −6

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+4 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings_runners.ex
+41 −6

Click to load diff…

modified lib/mix/tasks/gitgud.runner.create.ex
+23 −4

Click to load diff…

modified test/git_gud/workflows/fork_pull_request_test.exs
+5 −0

Click to load diff…

modified test/git_gud_web/controllers/runner_controller_test.exs
+9 −0

Click to load diff…

added test/git_gud_web/live/runner_audit_test.exs
+76 −0

Click to load diff…

Parents: d423d5a