Audit: bans and report decisions

5dd3a85 · Gabriel Morell · 2026-10-22 17:36

14 files +154 -14
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. 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 (`…/audit/secrets/before/<id>`). 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`, 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 (`…/audit/secrets/before/<id>`). 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/catalog.ex
+12 −0
@@ -148,6 +148,18 @@ defmodule GitGud.Audit.Catalog do
148 148 target: "pull_request",
149 149 keys: ~w(pull_request runs)
150 150 },
151 + # Moderation. A ban's reason is the moderator's own words, already
152 + # shown on the ban list. Bans keep their trail on the ban row too;
153 + # this puts them in the scope's log beside everything else.
154 + "ban.created" => %{category: "moderation", target: "ban", keys: ~w(reason expires_at)},
155 + "ban.lifted" => %{category: "moderation", target: "ban", keys: ~w()},
156 + "report.dismissed" => %{category: "moderation", target: "report", keys: ~w(target_type)},
157 + "report.actioned" => %{category: "moderation", target: "report", keys: ~w(target_type)},
158 + "report.content_moderated" => %{
159 + category: "moderation",
160 + target: "report",
161 + keys: ~w(target_type)
162 + },
151 163 # The pruner's own trace: how much went, from before when.
152 164 "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)}
153 165 }
modified lib/git_gud_web/components/audit_components.ex
+23 −0
@@ -154,6 +154,21 @@ defmodule GitGudWeb.AuditComponents do
154 154 "ci.fork_runs_rejected" ->
155 155 fork_runs("rejected", entry.metadata)
156 156
157 + "ban.created" ->
158 + ban_created(entry.metadata)
159 +
160 + "ban.lifted" ->
161 + "lifted the ban on"
162 +
163 + "report.dismissed" ->
164 + "dismissed #{report(entry.metadata)}"
165 +
166 + "report.actioned" ->
167 + "marked handled #{report(entry.metadata)}"
168 +
169 + "report.content_moderated" ->
170 + "replaced the reported content of #{report(entry.metadata)}"
171 +
157 172 "audit.pruned" ->
158 173 "pruned #{entry.metadata["count"]} audit entries"
159 174
@@ -200,6 +215,14 @@ defmodule GitGudWeb.AuditComponents do
200 215 defp fork_runs(verb, metadata),
201 216 do: "#{verb} #{metadata["runs"]} fork run(s) on pull request ##{metadata["pull_request"]}"
202 217
218 + defp ban_created(%{"expires_at" => until}) when is_binary(until),
219 + do: "banned, until #{String.slice(until, 0, 10)},"
220 +
221 + defp ban_created(_), do: "banned"
222 +
223 + defp report(%{"target_type" => type}) when is_binary(type), do: "a report on a #{type}"
224 + defp report(_), do: "a report"
225 +
203 226 defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ")
204 227
205 228 defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})"
modified lib/git_gud_web/live/moderation_actions.ex
+88 −13
@@ -10,9 +10,10 @@ defmodule GitGudWeb.ModerationActions do
10 10 `%Repository{}`) and `:moderator` in the assigns, plus a reload
11 11 callback the page provides to re-read its lists.
12 12
13 − Bans carry their own audit trail — who banned whom, when, why, and
14 − who lifted it are all on the row, and lifted rows are kept. There's
15 − no separate log to consult.
13 + Bans carry their own trail — who banned whom, when, why, and who
14 + lifted it are all on the row, and lifted rows are kept. Every action
15 + here is also recorded in the scope's audit log (`GitGud.Audit`), so
16 + the org's or repository's log reads complete.
16 17 """
17 18
18 19 import Phoenix.LiveView, only: [put_flash: 3]
@@ -81,7 +82,11 @@ defmodule GitGudWeb.ModerationActions do
81 82
82 83 defp do_dismiss_report(socket, id, reload) do
83 84 with {:ok, report} <- scoped_report(socket, id) do
84 − {:ok, _} = Reports.dismiss(report, socket.assigns.moderator)
85 + {:ok, _} =
86 + audited(socket, "report.dismissed", report, fn ->
87 + Reports.dismiss(report, socket.assigns.moderator)
88 + end)
89 +
85 90 socket |> reload.() |> put_flash(:info, "Report dismissed.")
86 91 else
87 92 _ -> put_flash(socket, :error, "That report isn't yours to act on.")
@@ -90,7 +95,11 @@ defmodule GitGudWeb.ModerationActions do
90 95
91 96 defp do_action_report(socket, id, reload) do
92 97 with {:ok, report} <- scoped_report(socket, id) do
93 − {:ok, _} = Reports.action(report, socket.assigns.moderator)
98 + {:ok, _} =
99 + audited(socket, "report.actioned", report, fn ->
100 + Reports.action(report, socket.assigns.moderator)
101 + end)
102 +
94 103 socket |> reload.() |> put_flash(:info, "Report marked handled.")
95 104 else
96 105 _ -> put_flash(socket, :error, "That report isn't yours to act on.")
@@ -100,9 +109,17 @@ defmodule GitGudWeb.ModerationActions do
100 109 defp do_moderate_report(socket, id, reload) do
101 110 moderator = socket.assigns.moderator
102 111
112 + # The content and the report change together, or neither does.
113 + moderate = fn report ->
114 + audited(socket, "report.content_moderated", report, fn ->
115 + with {:ok, _} <- Reports.moderate_target(report, moderator, nil) do
116 + Reports.action(report, moderator, "content replaced with mod message")
117 + end
118 + end)
119 + end
120 +
103 121 with {:ok, report} <- scoped_report(socket, id),
104 − {:ok, _} <- Reports.moderate_target(report, moderator, nil) do
105 − {:ok, _} = Reports.action(report, moderator, "content replaced with mod message")
122 + {:ok, _} <- moderate.(report) do
106 123 socket |> reload.() |> put_flash(:info, "Content replaced.")
107 124 else
108 125 _ -> put_flash(socket, :error, "Could not moderate that content.")
@@ -146,11 +163,26 @@ defmodule GitGudWeb.ModerationActions do
146 163 put_flash(socket, :error, "You can't ban yourself.")
147 164
148 165 true ->
149 − case Moderation.ban(scope, user,
150 − reason: reason,
151 − expires_at: expires_at,
152 − banned_by: moderator
153 − ) do
166 + banned =
167 + GitGud.Audit.transact(
168 + socket.assigns.current_scope,
169 + "ban.created",
170 + fn ->
171 + Moderation.ban(scope, user,
172 + reason: reason,
173 + expires_at: expires_at,
174 + banned_by: moderator
175 + )
176 + end,
177 + &ban_attrs(
178 + scope,
179 + user,
180 + &1,
181 + Map.reject(%{reason: reason, expires_at: expires_at}, fn {_, v} -> is_nil(v) end)
182 + )
183 + )
184 +
185 + case banned do
154 186 {:ok, _ban} ->
155 187 socket |> reload.() |> put_flash(:info, "#{user.handle} banned.")
156 188
@@ -164,13 +196,56 @@ defmodule GitGudWeb.ModerationActions do
164 196 ban = (id = GitGudWeb.RepoAdmin.parse_id(id)) && Moderation.get_ban(id)
165 197
166 198 if ban && in_scope?(ban, socket.assigns.scope) do
167 − {:ok, _} = Moderation.lift(ban, socket.assigns.moderator)
199 + {:ok, _} =
200 + GitGud.Audit.transact(
201 + socket.assigns.current_scope,
202 + "ban.lifted",
203 + fn -> Moderation.lift(ban, socket.assigns.moderator) end,
204 + &ban_attrs(socket.assigns.scope, ban.banned_user, &1, %{})
205 + )
206 +
168 207 socket |> reload.() |> put_flash(:info, "Ban lifted.")
169 208 else
170 209 put_flash(socket, :error, "That ban isn't yours to lift.")
171 210 end
172 211 end
173 212
213 + defp audited(socket, action, report, change) do
214 + subject =
215 + if action == "report.content_moderated" do
216 + case Reports.preview(report) do
217 + %{author_id: id} when is_integer(id) -> GitGud.Repo.get(Accounts.User, id)
218 + _ -> nil
219 + end
220 + end
221 +
222 + GitGud.Audit.transact(socket.assigns.current_scope, action, change, fn _ ->
223 + socket.assigns.scope
224 + |> place()
225 + |> Map.merge(%{
226 + target_id: report.id,
227 + target_label: "report ##{report.id}",
228 + subject: subject,
229 + metadata: %{target_type: report.target_type}
230 + })
231 + end)
232 + end
233 +
234 + # The banned person is the entry's subject and, by handle, its label.
235 + defp ban_attrs(scope, user, ban, metadata) do
236 + scope
237 + |> place()
238 + |> Map.merge(%{
239 + target_id: ban.id,
240 + target_label: user.handle,
241 + subject: user,
242 + metadata: metadata
243 + })
244 + end
245 +
246 + defp place(%Repository{} = repo), do: %{repository: repo}
247 + defp place(%Organization{} = org), do: %{organization: org}
248 +
174 249 # A moderator may only act on reports in their own scope; the id in
175 250 # the event is not taken on trust.
176 251 defp scoped_report(socket, id) do
modified test/git_gud_web/live/moderation_page_test.exs
+30 −0
@@ -21,6 +21,8 @@ defmodule GitGudWeb.ModerationPageTest do
21 21 ~p"/r/#{handle}/#{repo.name}/settings/moderation"
22 22 end
23 23
24 + defp audit, do: GitGud.Repo.all(GitGud.Audit.Entry)
25 +
24 26 defp org_setup(handle) do
25 27 admin = user_fixture()
26 28 {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle})
@@ -120,6 +122,13 @@ defmodule GitGudWeb.ModerationPageTest do
120 122
121 123 assert html =~ "Nothing reported here."
122 124 assert Reports.get_report!(report.id).state == "dismissed"
125 +
126 + assert [entry] = audit()
127 +
128 + assert {entry.action, entry.target_id, entry.actor_id} ==
129 + {"report.dismissed", report.id, owner.id}
130 +
131 + assert entry.metadata == %{"target_type" => "issue"}
123 132 end
124 133
125 134 test "replacing content keeps the original for admins", %{conn: conn} do
@@ -134,6 +143,14 @@ defmodule GitGudWeb.ModerationPageTest do
134 143 assert reloaded.moderated_at
135 144 assert reloaded.original_body == "the offending words"
136 145 assert Reports.get_report!(report.id).state == "actioned"
146 +
147 + # Done to the issue's author; never the words that were replaced.
148 + assert [entry] = audit()
149 +
150 + assert {entry.action, entry.subject_user_id} ==
151 + {"report.content_moderated", issue.author_id}
152 +
153 + refute inspect(entry) =~ "offending"
137 154 end
138 155
139 156 test "a moderator can't act on a report outside their scope", %{conn: conn} do
@@ -147,6 +164,7 @@ defmodule GitGudWeb.ModerationPageTest do
147 164
148 165 assert html =~ "isn&#39;t yours to act on"
149 166 assert Reports.get_report!(foreign.id).state == "open"
167 + assert audit() == []
150 168 end
151 169 end
152 170
@@ -165,6 +183,11 @@ defmodule GitGudWeb.ModerationPageTest do
165 183 assert html =~ nuisance.handle
166 184 assert Moderation.banned?(repo, nuisance)
167 185 assert {:error, :banned} = Issues.create_issue(repo, nuisance, %{"title" => "nope"})
186 +
187 + assert [entry] = audit()
188 + assert {entry.action, entry.subject_user_id} == {"ban.created", nuisance.id}
189 + assert {entry.target_label, entry.metadata} == {nuisance.handle, %{"reason" => "spam"}}
190 + assert entry.repository_id == repo.id
168 191 end
169 192
170 193 test "an unknown handle is reported", %{conn: conn} do
@@ -184,6 +207,7 @@ defmodule GitGudWeb.ModerationPageTest do
184 207
185 208 assert html =~ "can&#39;t ban yourself"
186 209 refute Moderation.banned?(repo, owner)
210 + assert audit() == []
187 211 end
188 212
189 213 test "lifting restores them", %{conn: conn} do
@@ -196,6 +220,8 @@ defmodule GitGudWeb.ModerationPageTest do
196 220
197 221 refute Moderation.banned?(repo, nuisance)
198 222 assert {:ok, _} = Issues.create_issue(repo, nuisance, %{"title" => "back"})
223 + assert [%{action: "ban.lifted", subject_user_id: sid}] = audit()
224 + assert sid == nuisance.id
199 225 end
200 226
201 227 test "a repo admin can't lift the owning org's ban", %{conn: conn} do
@@ -208,6 +234,7 @@ defmodule GitGudWeb.ModerationPageTest do
208 234
209 235 assert html =~ "isn&#39;t yours to lift"
210 236 assert Moderation.banned?(repo, nuisance)
237 + assert audit() == []
211 238 end
212 239
213 240 test "banning the reported author works from the queue", %{conn: conn} do
@@ -237,6 +264,9 @@ defmodule GitGudWeb.ModerationPageTest do
237 264 assert [ban] = Moderation.list_bans(repo)
238 265 assert ban.expires_at
239 266 assert Moderation.banned?(repo, nuisance)
267 +
268 + assert [entry] = audit()
269 + assert entry.metadata == %{"expires_at" => DateTime.to_iso8601(ban.expires_at)}
240 270 end
241 271
242 272 test "an org ban shows on the org page, not the repo's list", %{conn: conn} do

Parents: 5db6178