Audit: /admin/audit, and the instance's own actions

7bb4f3f · Gabriel Morell · 2026-10-22 18:00

30 files +688 -71
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −1
@@ -65,7 +65,7 @@ reinvent them.
65 65 - **Invites** — single-use tokens with create / list / revoke per user; UI at `/users/settings/invites`
66 66 - **Org visibility**: `public` / `internal` / `private`. Repos can't be more open than their org; tightening an org cascades to over-public repos in one transaction. Public orgs are accessible to anonymous viewers; private orgs require membership.
67 67 - **Org membership by invitation** at `/orgs/:handle/settings/members` — an admin invites by handle or email and the invitee accepts from their dashboard; nobody is added to an org without agreeing. Withdraw a pending invite, change roles, remove members, with a last-admin guard so an org can never end up admin-less
68 − - **Audit log** at `/orgs/:handle/settings/audit`, and per repository at `/r/:owner/:name/settings/audit` — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org's own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins (and a personal repository's owner), in sudo mode; filters are path segments, by category, person and repository (`…/audit/secrets/actor/alice/repo/web/before/<id>`), and a deleted account's or repository's entries can still be picked out by its old name. More of the org's history joins it slice by slice ([docs/plans/org-audit-log.md](docs/plans/org-audit-log.md)); entries are kept forever unless `GitGud.Audit` `retention_days` is set
68 + - **Audit log** at `/orgs/:handle/settings/audit`, per repository at `/r/:owner/:name/settings/audit`, and for instance admins at `/admin/audit` — everything, plus federation blocks, instance settings and registration invites, and the only view with each entry's address and user agent — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org's own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins (and a personal repository's owner), in sudo mode; filters are path segments, by category, person and repository (`…/audit/secrets/actor/alice/repo/web/before/<id>`), and a deleted account's or repository's entries can still be picked out by its old name. More of the org's history joins it slice by slice ([docs/plans/org-audit-log.md](docs/plans/org-audit-log.md)); entries are kept forever unless `GitGud.Audit` `retention_days` is set
69 69 - **Moderation for owners**
70 70 - Org and repo admins get their own queue at `/orgs/:handle/settings/moderation` and `/r/:owner/:name/settings/moderation` — reports about *their* content, which previously only an instance admin could see
71 71 - **Scoped bans** on local users: a repo admin can stop someone acting in their repo, an org admin across every repo the org owns. Neither reaches further; suspending an account instance-wide stays an instance admin's power
modified lib/git_gud/audit.ex
+13 −2
@@ -257,8 +257,9 @@ defmodule GitGud.Audit do
257 257 done to them, and what they did outside any org or repository).
258 258
259 259 Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`,
260 − and `:actor_label` / `:repository_label` for entries whose account or
261 − repository is gone.
260 + `:actor_label` / `:repository_label` / `:organization_label` for
261 + entries whose account, repository or org is gone, and
262 + `instance_only: true` for what belongs to no org or repository.
262 263 `cursor` is nil, `{:before, id}` (older) or `{:after, id}` (newer).
263 264 Returns `{entries, %{older: id | nil, newer: id | nil}}`: the cursors
264 265 for the next page in each direction, nil when there's none.
@@ -367,6 +368,16 @@ defmodule GitGud.Audit do
367 368 {:repository_label, label}, q when is_binary(label) ->
368 369 from(e in q, where: is_nil(e.repository_id) and e.repository_label == ^label)
369 370
371 + {:organization_label, label}, q when is_binary(label) ->
372 + from(e in q, where: is_nil(e.organization_id) and e.organization_label == ^label)
373 +
374 + # What belongs to the instance itself, not to any org or repository.
375 + {:instance_only, true}, q ->
376 + from(e in q,
377 + where: is_nil(e.organization_id) and is_nil(e.repository_id),
378 + where: is_nil(e.organization_label) and is_nil(e.repository_label)
379 + )
380 +
370 381 _, q ->
371 382 q
372 383 end)
modified lib/git_gud/audit/catalog.ex
+41 −0
@@ -160,6 +160,47 @@ defmodule GitGud.Audit.Catalog do
160 160 target: "report",
161 161 keys: ~w(target_type)
162 162 },
163 + # The instance: federation, instance settings, registration invites.
164 + # These belong to no org or repository, and show only on the
165 + # instance view. A remote actor is labelled by its public URL.
166 + "instance.blocked" => %{category: "instance", target: "instance", keys: ~w(severity reason)},
167 + "instance.unblocked" => %{category: "instance", target: "instance", keys: ~w()},
168 + "instance.allowlisted" => %{category: "instance", target: "instance", keys: ~w()},
169 + "instance.unallowlisted" => %{category: "instance", target: "instance", keys: ~w()},
170 + "suggested_block.approved" => %{category: "instance", target: "instance", keys: ~w()},
171 + "suggested_block.dismissed" => %{category: "instance", target: "instance", keys: ~w()},
172 + "remote_actor.blocked" => %{category: "instance", target: "remote_actor", keys: ~w(reason)},
173 + "remote_actor.unblocked" => %{category: "instance", target: "remote_actor", keys: ~w()},
174 + "remote_actor.suspended" => %{category: "instance", target: "remote_actor", keys: ~w()},
175 + "inbox.quarantine_approved" => %{
176 + category: "instance",
177 + target: "inbox_delivery",
178 + keys: ~w(source_actor)
179 + },
180 + "inbox.quarantine_rejected" => %{
181 + category: "instance",
182 + target: "inbox_delivery",
183 + keys: ~w(source_actor)
184 + },
185 + "report.forwarded" => %{category: "moderation", target: "report", keys: ~w(target_type)},
186 + "report.content_restored" => %{
187 + category: "moderation",
188 + target: "report",
189 + keys: ~w(target_type)
190 + },
191 + "instance_setting.changed" => %{
192 + category: "instance",
193 + target: "instance_setting",
194 + keys: ~w(from to)
195 + },
196 + "packages.pruned" => %{
197 + category: "instance",
198 + target: nil,
199 + keys: ~w(versions bytes failed)
200 + },
201 + # A registration invite, by id: its link is the credential.
202 + "invite.created" => %{category: "instance", target: "invite", keys: ~w()},
203 + "invite.revoked" => %{category: "instance", target: "invite", keys: ~w()},
163 204 # The pruner's own trace: how much went, from before when.
164 205 "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)}
165 206 }
modified lib/git_gud_web/components/audit_components.ex
+54 −0
@@ -169,6 +169,57 @@ defmodule GitGudWeb.AuditComponents do
169 169 "report.content_moderated" ->
170 170 "replaced the reported content of #{report(entry.metadata)}"
171 171
172 + "instance.blocked" ->
173 + "blocked the instance (#{entry.metadata["severity"]})"
174 +
175 + "instance.unblocked" ->
176 + "unblocked the instance"
177 +
178 + "instance.allowlisted" ->
179 + "allowlisted the instance"
180 +
181 + "instance.unallowlisted" ->
182 + "took off the allowlist the instance"
183 +
184 + "suggested_block.approved" ->
185 + "approved the suggested block of"
186 +
187 + "suggested_block.dismissed" ->
188 + "dismissed the suggested block of"
189 +
190 + "remote_actor.blocked" ->
191 + "blocked the remote actor"
192 +
193 + "remote_actor.unblocked" ->
194 + "unblocked the remote actor"
195 +
196 + "remote_actor.suspended" ->
197 + "suspended the remote actor"
198 +
199 + "inbox.quarantine_approved" ->
200 + "let in a quarantined delivery from"
201 +
202 + "inbox.quarantine_rejected" ->
203 + "rejected a quarantined delivery from"
204 +
205 + "report.forwarded" ->
206 + "forwarded to its source #{report(entry.metadata)}"
207 +
208 + "report.content_restored" ->
209 + "restored the content behind #{report(entry.metadata)}"
210 +
211 + "instance_setting.changed" ->
212 + "changed #{from_to(entry.metadata)} the setting"
213 +
214 + "packages.pruned" ->
215 + packages_pruned(entry.metadata)
216 +
217 + "invite.created" ->
218 + "created a registration invite"
219 +
220 + "invite.revoked" ->
221 + "revoked a registration invite"
222 +
172 223 "audit.pruned" ->
173 224 "pruned #{entry.metadata["count"]} audit entries"
174 225
@@ -223,6 +274,9 @@ defmodule GitGudWeb.AuditComponents do
223 274 defp report(%{"target_type" => type}) when is_binary(type), do: "a report on a #{type}"
224 275 defp report(_), do: "a report"
225 276
277 + defp packages_pruned(metadata),
278 + do: "pruned #{metadata["versions"]} package version(s), #{metadata["bytes"]} bytes"
279 +
226 280 defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ")
227 281
228 282 defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})"
modified lib/git_gud_web/components/landing_components.ex
+6 −0
@@ -199,6 +199,12 @@ defmodule GitGudWeb.LandingComponents do
199 199 label="Federation admin"
200 200 navigate={~p"/admin/federation"}
201 201 />
202 + <.quick_link
203 + :if={@user.is_admin}
204 + icon="hero-clipboard-document-list"
205 + label="Audit log"
206 + navigate={~p"/admin/audit"}
207 + />
202 208 </section>
203 209 """
204 210 end
modified lib/git_gud_web/live/admin_federation_live/index.ex
+191 −37

Click to load diff…

modified lib/git_gud_web/live/admin_packages_live/index.ex
+33 −7

Click to load diff…

added lib/git_gud_web/live/audit_live/admin.ex
+65 −0

Click to load diff…

modified lib/git_gud_web/live/audit_live/filters.ex
+22 −7

Click to load diff…

modified lib/git_gud_web/live/audit_live/org.ex
+2 −1

Click to load diff…

modified lib/git_gud_web/live/audit_live/page.ex
+63 −13

Click to load diff…

modified lib/git_gud_web/live/audit_live/repo.ex
+1 −0

Click to load diff…

modified lib/git_gud_web/live/user_live/invites.ex
+18 −2

Click to load diff…

modified lib/git_gud_web/router.ex
+5 −0

Click to load diff…

modified test/git_gud_web/live/admin_packages_test.exs
+18 −1

Click to load diff…

added test/git_gud_web/live/audit_live/admin_test.exs
+155 −0

Click to load diff…

Parents: ba66a27