neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Audit: bans and report decisions
5dd3a85 · Gabriel Morell · 2026-10-22 17:36
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'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'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'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