Audit: the substrate — entries, catalog, recording in the change's transaction

8ccf537 · Gabriel Morell · 2026-10-20 21:31

32 files +1150 -10
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -257,6 +257,7 @@ These all live in `config/*.exs`. Key ones:
257 257 | `GITGUD_IMPORT_ALLOW_PRIVATE` (env) | `1`/`true`/`yes` lets imports, mirrors and upstream sync reach private, loopback and link-local addresses (`GitGud.OutboundUrl` `allow_private: [:import]`). The scheme rule and the no-local-paths rule still apply | unset (public addresses only) |
258 258 | `GITGUD_WEBHOOK_ALLOW_PRIVATE` (env) | The same for webhook delivery (`:webhook`). Webhooks pointing at a LAN host stop delivering until this is set | unset |
259 259 | `GITGUD_FEDERATION_ALLOW_PRIVATE` (env) | The same for ActivityPub actor fetches and inbox delivery (`:federation`) | unset |
260 +| `GitGud.Audit` `retention_days` | Audit log entries older than this many days are pruned daily. Config only, not an admin setting, so shortening the trail takes a deploy; boot refuses anything but `nil` or a positive integer | `nil` (kept forever) |
260 261 | `GitGud.Workflows.Workers.StaleJobReaper` `stale_after_minutes` | A `running` job whose runner hasn't sent an `UpdateTask` or `UpdateLog` (`heartbeat_at`) for this long is re-queued | `5` |
261 262 | `GitGud.Packages.Token` `private_key_pem` | RS256 PEM that signs OCI registry Bearer tokens (env: `GITGUD_REGISTRY_PRIVATE_KEY`) | `nil` (registry auth disabled) |
262 263 | `GitGud.Packages.Token` `issuer` / `audience` / `key_id` / `ttl_seconds` | JWT claim defaults zot must agree on | `gitgud-registry` / `container_registry` / `gitgud-registry-1` / `300` |
modified config/config.exs
+6 −0
@@ -54,6 +54,8 @@ config :git_gud, Oban,
54 54 {Oban.Plugins.Cron,
55 55 crontab: [
56 56 {"@daily", GitGud.Federation.Workers.Retention},
57 + # Audit log retention; a no-op while retention_days is nil.
58 + {"@daily", GitGud.Audit.Workers.Prune},
57 59 # Walk a batch of stale remote actors every hour so cached public
58 60 # keys don't drift even when nobody's touching the actor.
59 61 {"@hourly", GitGud.Federation.Workers.RefreshRemoteActors},
@@ -78,6 +80,10 @@ config :git_gud, Oban,
78 80 # tolerant of slow runners. Tune per deployment.
79 81 config :git_gud, GitGud.Workflows.Workers.StaleJobReaper, stale_after_minutes: 5
80 82
83 +# Audit log retention (GitGud.Audit). nil = keep every entry forever;
84 +# an integer N prunes entries older than N days, daily.
85 +config :git_gud, GitGud.Audit, retention_days: nil
86 +
81 87 # Federation retention. nil = keep forever. Tune per deployment.
82 88 config :git_gud, GitGud.Federation.Workers.Retention,
83 89 inbox_deliveries_days: nil,
modified lib/git_gud/accounts/scope.ex
+16 −1
@@ -18,7 +18,9 @@ defmodule GitGud.Accounts.Scope do
18 18
19 19 alias GitGud.Accounts.User
20 20
21 − defstruct user: nil
21 + # `ip` and `user_agent` describe the request the scope came from, for
22 + # the audit log (`GitGud.Audit`); nothing decides anything on them.
23 + defstruct user: nil, ip: nil, user_agent: nil
22 24
23 25 @doc """
24 26 Creates a scope for the given user.
@@ -30,4 +32,17 @@ defmodule GitGud.Accounts.Scope do
30 32 end
31 33
32 34 def for_user(nil), do: nil
35 +
36 + @doc """
37 + Note the request's client address (a tuple or a string) and user
38 + agent on the scope. A nil scope stays nil.
39 + """
40 + def put_request(nil, _ip, _user_agent), do: nil
41 +
42 + def put_request(%__MODULE__{} = scope, ip, user_agent) do
43 + %{scope | ip: GitGud.Audit.Actor.ip_string(ip), user_agent: truncate(user_agent)}
44 + end
45 +
46 + defp truncate(ua) when is_binary(ua), do: String.slice(ua, 0, 512)
47 + defp truncate(_), do: nil
33 48 end
modified lib/git_gud/application.ex
+2 −0
@@ -18,6 +18,8 @@ defmodule GitGud.Application do
18 18 # lets them call back into /_hooks and /_ssh.
19 19 :ok = GitGudWeb.Plugs.HookAuth.put_secret()
20 20 :ok = GitGudWeb.PackagesWebhookController.warn_if_unconfigured()
21 + # A bad audit retention value refuses to boot rather than prune.
22 + _ = GitGud.Audit.retention_days!()
21 23
22 24 topologies = Application.get_env(:libcluster, :topologies, [])
23 25
added lib/git_gud/audit.ex
+391 −0
@@ -0,0 +1,391 @@
1 +defmodule GitGud.Audit do
2 + @moduledoc """
3 + Who changed what, in an organization, a repository or the instance.
4 +
5 + An audit entry is written in the same transaction as the change it
6 + describes: a change that fails leaves no entry, and an entry that
7 + can't be written undoes the change. That's the difference from
8 + `GitGud.Events`, which is issue and PR history and best-effort by
9 + design — a missing audit entry is a missing security record.
10 +
11 + Audit.transact(scope, "repo_secret.set",
12 + fn -> Secrets.put_repo_secret(repo, name, value) end,
13 + fn _secret -> %{repository: repo, target_label: name} end)
14 +
15 + The call site — the LiveView handler or controller that knows who
16 + acted — wraps the context call; contexts don't take an actor. The
17 + actor is a `GitGud.Accounts.Scope` (a signed-in request, with its
18 + address and user agent) or a `GitGud.Audit.Actor`.
19 +
20 + What may be recorded is `GitGud.Audit.Catalog`'s business. Values of
21 + secrets, tokens and the like never are: metadata keys outside an
22 + action's list are dropped.
23 +
24 + Every committed entry also leaves one `Logger.notice` with
25 + `audit: true` metadata, for operators who ship logs. Entries are never
26 + federated. They are kept forever unless
27 + `config :git_gud, GitGud.Audit, retention_days: N` says otherwise
28 + (`GitGud.Audit.Workers.Prune`).
29 + """
30 +
31 + import Ecto.Query, warn: false
32 +
33 + require Logger
34 +
35 + alias GitGud.Accounts.Scope
36 + alias GitGud.Accounts.User
37 + alias GitGud.Audit.Actor
38 + alias GitGud.Audit.Entry
39 + alias GitGud.Organizations.Organization
40 + alias GitGud.Repo
41 + alias GitGud.Repositories.Repository
42 +
43 + @type actor :: Scope.t() | Actor.t()
44 +
45 + @typedoc """
46 + What an entry is about, as `describe` returns it or `insert!/3` takes it:
47 +
48 + * `:organization` / `:repository` — the structs; ids and labels are
49 + taken from them, and a repository's organization fills in
50 + `:organization` when it isn't given
51 + * `:target_type`, `:target_id`, `:target_label` — the thing acted on
52 + (`:target_type` defaults to the catalog's)
53 + * `:subject` — the `%User{}` acted on (an invitee, a banned user)
54 + * `:metadata` — a map; only the catalog's keys survive
55 + """
56 + @type attrs :: map()
57 +
58 + @doc """
59 + Run `fun` and record `action` in one transaction.
60 +
61 + `fun` returns `:ok`, `{:ok, value}`, `:error` or `{:error, reason}`,
62 + and `transact/5` returns exactly that. On success `describe.(value)`
63 + gives the entry's attrs, or `:skip` when nothing actually changed.
64 + Any error rolls the transaction back and records nothing; an entry
65 + that can't be inserted raises and rolls the change back.
66 +
67 + Options:
68 +
69 + * `record: :before` — insert the entry first and run `fun` second
70 + (`describe` gets `:before`). For changes with effects outside the
71 + database — deleting a repository removes its directory inside the
72 + transaction, and an insert failing after that would leave the files
73 + gone and the row back.
74 + """
75 + @spec transact(actor(), String.t(), (-> term()), (term() -> attrs() | :skip), keyword()) ::
76 + term()
77 + def transact(actor, action, fun, describe, opts \\ [])
78 + when is_function(fun, 0) and is_function(describe, 1) do
79 + base = base!(actor, action)
80 +
81 + result =
82 + Repo.transact(fn ->
83 + case Keyword.get(opts, :record, :after) do
84 + :before ->
85 + entry = insert_described!(base, describe.(:before))
86 + wrap(fun.(), entry)
87 +
88 + :after ->
89 + case fun.() do
90 + ok when ok == :ok or (is_tuple(ok) and elem(ok, 0) == :ok) ->
91 + value = if ok == :ok, do: :ok, else: elem(ok, 1)
92 + {:ok, {ok, insert_described!(base, describe.(value))}}
93 +
94 + other ->
95 + {:error, {:returned, other}}
96 + end
97 + end
98 + end)
99 +
100 + case result do
101 + {:ok, {original, entry}} ->
102 + if entry, do: notice(entry)
103 + original
104 +
105 + {:error, {:returned, original}} ->
106 + original
107 + end
108 + end
109 +
110 + defp wrap(result, entry) do
111 + if result == :ok or (is_tuple(result) and elem(result, 0) == :ok),
112 + do: {:ok, {result, entry}},
113 + else: {:error, {:returned, result}}
114 + end
115 +
116 + @doc """
117 + Record `action` with no change around it: something that already
118 + happened (a prune, a registration recorded by its own context).
119 + """
120 + @spec record(actor(), String.t(), attrs()) :: Entry.t()
121 + def record(actor, action, attrs) do
122 + entry = insert!(actor, action, attrs)
123 + notice(entry)
124 + entry
125 + end
126 +
127 + @doc """
128 + Insert an entry inside a transaction the caller already has open.
129 +
130 + Leaves no log line, since the transaction may still roll back; call
131 + `notice/1` with the entry once it has committed.
132 + """
133 + @spec insert!(actor(), String.t(), attrs()) :: Entry.t()
134 + def insert!(actor, action, attrs) do
135 + actor |> base!(action) |> insert_described!(attrs)
136 + end
137 +
138 + @doc "Add an entry to an `Ecto.Multi`; `attrs` may be a function of the changes so far."
139 + @spec multi(Ecto.Multi.t(), term(), actor(), String.t(), attrs() | (map() -> attrs())) ::
140 + Ecto.Multi.t()
141 + def multi(%Ecto.Multi{} = multi, name, actor, action, attrs) do
142 + base = base!(actor, action)
143 +
144 + Ecto.Multi.insert(multi, name, fn changes ->
145 + attrs = if is_function(attrs, 1), do: attrs.(changes), else: attrs
146 + base |> Map.merge(entry_attrs(attrs)) |> Entry.changeset()
147 + end)
148 + end
149 +
150 + @doc "The log line for a committed entry."
151 + @spec notice(Entry.t()) :: :ok
152 + def notice(%Entry{} = entry) do
153 + where =
154 + [entry.target_label, entry.repository_label || entry.organization_label]
155 + |> Enum.reject(&is_nil/1)
156 + |> Enum.join(" in ")
157 +
158 + Logger.notice(
159 + "audit: #{entry.action} by #{entry.actor_label}" <>
160 + if(where != "", do: " — #{where}", else: ""),
161 + audit: true,
162 + audit_action: entry.action,
163 + audit_id: entry.id
164 + )
165 + end
166 +
167 + # ── reading ──────────────────────────────────────────────────────────
168 +
169 + @default_limit 50
170 + @max_limit 100
171 +
172 + @doc """
173 + A page of entries, newest first.
174 +
175 + `scope` is `{:org, org}` (the org's own entries and its repositories'),
176 + `{:repo, repo}`, `:instance` (everything) or `{:user, user}` (what was
177 + done to them, and what they did outside any org or repository).
178 +
179 + Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`.
180 + `cursor` is nil, `{:before, id}` (older) or `{:after, id}` (newer).
181 + Returns `{entries, %{older: id | nil, newer: id | nil}}`: the cursors
182 + for the next page in each direction, nil when there's none.
183 + """
184 + def list(scope, filters \\ %{}, cursor \\ nil, limit \\ @default_limit) do
185 + limit = limit |> max(1) |> min(@max_limit)
186 +
187 + base =
188 + Entry
189 + |> scoped(scope)
190 + |> filtered(filters)
191 +
192 + {rows, more?} =
193 + case cursor do
194 + {:after, id} ->
195 + rows =
196 + from(e in base, where: e.id > ^id, order_by: [asc: e.id], limit: ^(limit + 1))
197 + |> Repo.all()
198 +
199 + {Enum.reverse(Enum.take(rows, limit)), length(rows) > limit}
200 +
201 + before ->
202 + query = from(e in base, order_by: [desc: e.id], limit: ^(limit + 1))
203 +
204 + query =
205 + case before do
206 + {:before, id} -> from(e in query, where: e.id < ^id)
207 + _ -> query
208 + end
209 +
210 + rows = Repo.all(query)
211 + {Enum.take(rows, limit), length(rows) > limit}
212 + end
213 +
214 + rows = Repo.preload(rows, [:actor])
215 +
216 + cursors =
217 + case {cursor, rows} do
218 + {_, []} ->
219 + %{older: nil, newer: nil}
220 +
221 + {{:after, _}, rows} ->
222 + %{older: List.last(rows).id, newer: if(more?, do: hd(rows).id)}
223 +
224 + {{:before, _}, rows} ->
225 + %{older: if(more?, do: List.last(rows).id), newer: hd(rows).id}
226 +
227 + {nil, rows} ->
228 + %{older: if(more?, do: List.last(rows).id), newer: nil}
229 + end
230 +
231 + {rows, cursors}
232 + end
233 +
234 + defp scoped(query, {:org, %Organization{id: id}}),
235 + do: from(e in query, where: e.organization_id == ^id)
236 +
237 + defp scoped(query, {:repo, %Repository{id: id}}),
238 + do: from(e in query, where: e.repository_id == ^id)
239 +
240 + defp scoped(query, {:user, %User{id: id}}),
241 + do:
242 + from(e in query,
243 + where:
244 + e.subject_user_id == ^id or
245 + (e.actor_id == ^id and is_nil(e.organization_id) and is_nil(e.repository_id))
246 + )
247 +
248 + defp scoped(query, :instance), do: query
249 +
250 + defp filtered(query, filters) do
251 + Enum.reduce(filters, query, fn
252 + {:category, c}, q when is_binary(c) ->
253 + from(e in q, where: e.category == ^c)
254 +
255 + {:actor_id, id}, q when is_integer(id) ->
256 + from(e in q, where: e.actor_id == ^id)
257 +
258 + {:repository_id, id}, q when is_integer(id) ->
259 + from(e in q, where: e.repository_id == ^id)
260 +
261 + {:organization_id, id}, q when is_integer(id) ->
262 + from(e in q, where: e.organization_id == ^id)
263 +
264 + _, q ->
265 + q
266 + end)
267 + end
268 +
269 + # ── retention ────────────────────────────────────────────────────────
270 +
271 + @doc """
272 + Delete entries written before `cutoff`, `batch` at a time so no one
273 + statement holds its locks for long. Returns how many went.
274 + """
275 + @spec prune(DateTime.t(), pos_integer()) :: non_neg_integer()
276 + def prune(%DateTime{} = cutoff, batch \\ 5_000) do
277 + prune_batches(cutoff, batch, 0)
278 + end
279 +
280 + defp prune_batches(cutoff, batch, total) do
281 + ids = from(e in Entry, where: e.inserted_at < ^cutoff, select: e.id, limit: ^batch)
282 + {count, _} = Repo.delete_all(from(e in Entry, where: e.id in subquery(ids)))
283 +
284 + if count < batch, do: total + count, else: prune_batches(cutoff, batch, total + count)
285 + end
286 +
287 + @doc """
288 + The configured retention in days, nil to keep everything. Raises on
289 + anything else, so a typo can't quietly delete the trail; checked at
290 + boot by `GitGud.Application`.
291 + """
292 + @spec retention_days!() :: pos_integer() | nil
293 + def retention_days! do
294 + case Keyword.get(Application.get_env(:git_gud, __MODULE__, []), :retention_days) do
295 + nil ->
296 + nil
297 +
298 + days when is_integer(days) and days > 0 ->
299 + days
300 +
301 + other ->
302 + raise ArgumentError,
303 + "config :git_gud, GitGud.Audit, retention_days: must be nil or a positive integer, " <>
304 + "got: #{inspect(other)}"
305 + end
306 + end
307 +
308 + # ── building ─────────────────────────────────────────────────────────
309 +
310 + # Who and what, checked before the change runs: an unknown action or
311 + # an actor that can't be described raises with nothing changed.
312 + defp base!(actor, action) do
313 + if GitGud.Audit.Catalog.fetch(action) == nil,
314 + do:
315 + raise(
316 + ArgumentError,
317 + "unknown audit action #{inspect(action)}; add it to GitGud.Audit.Catalog"
318 + )
319 +
320 + Map.put(actor_attrs(actor), :action, action)
321 + end
322 +
323 + defp actor_attrs(%Scope{user: %User{} = user} = scope),
324 + do: %{
325 + actor_type: "user",
326 + actor_id: user.id,
327 + actor_label: user.handle,
328 + via: "web",
329 + ip: scope.ip,
330 + user_agent: scope.user_agent
331 + }
332 +
333 + defp actor_attrs(%Actor{} = actor),
334 + do: %{
335 + actor_type: actor.type,
336 + actor_id: actor.id,
337 + actor_ref: actor.ref,
338 + actor_label: actor.label,
339 + via: actor.via,
340 + ip: actor.ip,
341 + user_agent: actor.user_agent
342 + }
343 +
344 + defp actor_attrs(other),
345 + do:
346 + raise(
347 + ArgumentError,
348 + "an audit actor is a signed-in Scope or an Audit.Actor, got: #{inspect(other)}"
349 + )
350 +
351 + defp insert_described!(_base, :skip), do: nil
352 +
353 + defp insert_described!(base, attrs) when is_map(attrs) do
354 + base
355 + |> Map.merge(entry_attrs(attrs))
356 + |> Entry.changeset()
357 + |> Repo.insert!()
358 + end
359 +
360 + defp entry_attrs(attrs) do
361 + {repo, attrs} = Map.pop(attrs, :repository)
362 + {org, attrs} = Map.pop(attrs, :organization)
363 + {subject, attrs} = Map.pop(attrs, :subject)
364 +
365 + attrs
366 + |> Map.merge(repository_attrs(repo))
367 + |> Map.merge(organization_attrs(org || repository_org(repo)))
368 + |> Map.merge(if subject, do: %{subject_user_id: subject.id}, else: %{})
369 + end
370 +
371 + defp repository_attrs(nil), do: %{}
372 +
373 + defp repository_attrs(%Repository{} = repo) do
374 + repo = Repo.preload(repo, [:owner, :organization])
375 +
376 + %{
377 + repository_id: repo.id,
378 + repository_label: "#{GitGud.Repositories.Storage.repo_handle(repo)}/#{repo.name}"
379 + }
380 + end
381 +
382 + defp repository_org(%Repository{organization_id: id} = repo) when not is_nil(id),
383 + do: Repo.preload(repo, :organization).organization
384 +
385 + defp repository_org(_), do: nil
386 +
387 + defp organization_attrs(nil), do: %{}
388 +
389 + defp organization_attrs(%Organization{} = org),
390 + do: %{organization_id: org.id, organization_label: "@#{org.handle}"}
391 +end
added lib/git_gud/audit/actor.ex
+66 −0

Click to load diff…

added lib/git_gud/audit/catalog.ex
+44 −0

Click to load diff…

added lib/git_gud/audit/entry.ex
+131 −0

Click to load diff…

added lib/git_gud/audit/workers/prune.ex
+41 −0

Click to load diff…

modified lib/git_gud_web/endpoint.ex
+4 −3

Click to load diff…

modified lib/git_gud_web/plugs/client_ip.ex
+13 −4

Click to load diff…

modified lib/git_gud_web/user_auth.ex
+12 −2

Click to load diff…

added priv/repo/migrations/20261020211440_create_audit_entries.exs
+43 −0

Click to load diff…

added test/git_gud/audit/workers/prune_test.exs
+73 −0

Click to load diff…

added test/git_gud/audit_test.exs
+262 −0

Click to load diff…

added test/git_gud_web/scope_request_test.exs
+45 −0

Click to load diff…

Parents: 88a5fab