neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Audit: /admin/audit, and the instance's own actions
7bb4f3f · Gabriel Morell · 2026-10-22 18:00
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−1
| 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 | 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 | 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 | 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 | 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 | 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 | 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