neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Audit: repositories — created, forked, reconfigured, deleted — and their own audit page
ec2ea47 · Gabriel Morell · 2026-10-22 14:22
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` — who changed which CI secret or variable, in the org or any of its repositories, written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins, 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. 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.ex
+68
−8
| 76 | 76 | term() |
| 77 | 77 | def transact(actor, action, fun, describe, opts \\ []) |
| 78 | 78 | when is_function(fun, 0) and is_function(describe, 1) do |
| 79 | − base = base!(actor, action) | |
| 79 | + transact_all( | |
| 80 | + actor, | |
| 81 | + [action], | |
| 82 | + fun, | |
| 83 | + fn value -> | |
| 84 | + case describe.(value) do | |
| 85 | + :skip -> [] | |
| 86 | + attrs -> [{action, attrs}] | |
| 87 | + end | |
| 88 | + end, | |
| 89 | + opts | |
| 90 | + ) | |
| 91 | + end | |
| 92 | + | |
| 93 | + @doc """ | |
| 94 | + `transact/5` for a change that records several entries — a settings | |
| 95 | + save that also changed visibility, a fork that leaves an entry on | |
| 96 | + both repositories. `actions` lists every action `describe` may | |
| 97 | + return, so each is checked before anything changes; `describe` | |
| 98 | + returns `[{action, attrs}]`, empty when nothing changed. | |
| 99 | + """ | |
| 100 | + @spec transact_all( | |
| 101 | + actor(), | |
| 102 | + [String.t()], | |
| 103 | + (-> term()), | |
| 104 | + (term() -> [{String.t(), attrs()}]), | |
| 105 | + keyword() | |
| 106 | + ) :: term() | |
| 107 | + def transact_all(actor, actions, fun, describe, opts \\ []) | |
| 108 | + when is_list(actions) and is_function(fun, 0) and is_function(describe, 1) do | |
| 109 | + bases = Map.new(actions, &{&1, base!(actor, &1)}) | |
| 110 | + | |
| 111 | + insert_all! = fn value -> | |
| 112 | + for {action, attrs} <- describe.(value) do | |
| 113 | + base = | |
| 114 | + Map.get(bases, action) || raise ArgumentError, "#{action} not in #{inspect(actions)}" | |
| 115 | + | |
| 116 | + insert_described!(base, attrs) | |
| 117 | + end | |
| 118 | + end | |
| 80 | 119 | |
| 81 | 120 | result = |
| 82 | 121 | Repo.transact(fn -> |
| 83 | 122 | case Keyword.get(opts, :record, :after) do |
| 84 | 123 | :before -> |
| 85 | − entry = insert_described!(base, describe.(:before)) | |
| 86 | − wrap(fun.(), entry) | |
| 124 | + entries = insert_all!.(:before) | |
| 125 | + wrap(fun.(), entries) | |
| 87 | 126 | |
| 88 | 127 | :after -> |
| 89 | 128 | case fun.() do |
| 90 | 129 | ok when ok == :ok or (is_tuple(ok) and elem(ok, 0) == :ok) -> |
| 91 | 130 | value = if ok == :ok, do: :ok, else: elem(ok, 1) |
| 92 | − {:ok, {ok, insert_described!(base, describe.(value))}} | |
| 131 | + {:ok, {ok, insert_all!.(value)}} | |
| 93 | 132 | |
| 94 | 133 | other -> |
| 95 | 134 | {:error, {:returned, other}} |
| 98 | 137 | end) |
| 99 | 138 | |
| 100 | 139 | case result do |
| 101 | − {:ok, {original, entry}} -> | |
| 102 | − if entry, do: notice(entry) | |
| 140 | + {:ok, {original, entries}} -> | |
| 141 | + Enum.each(entries, ¬ice/1) | |
| 103 | 142 | original |
| 104 | 143 | |
| 105 | 144 | {:error, {:returned, original}} -> |
| 107 | 146 | end |
| 108 | 147 | end |
| 109 | 148 | |
| 110 | − defp wrap(result, entry) do | |
| 149 | + defp wrap(result, entries) do | |
| 111 | 150 | if result == :ok or (is_tuple(result) and elem(result, 0) == :ok), |
| 112 | − do: {:ok, {result, entry}}, | |
| 151 | + do: {:ok, {result, entries}}, | |
| 113 | 152 | else: {:error, {:returned, result}} |
| 114 | 153 | end |
| 115 | 154 |
| 164 | 203 | ) |
| 165 | 204 | end |
| 166 | 205 | |
| 206 | + @doc """ | |
| 207 | + The attrs for a `repo.created` entry, from the repository and the | |
| 208 | + attrs it was created with: its visibility, and an import's source | |
| 209 | + host — never the rest of the clone URL, which can carry a token. | |
| 210 | + """ | |
| 211 | + @spec repo_created(Repository.t(), map()) :: attrs() | |
| 212 | + def repo_created(%Repository{} = repo, attrs) do | |
| 213 | + host = | |
| 214 | + case attrs["clone_addr"] || attrs[:clone_addr] do | |
| 215 | + url when is_binary(url) and url != "" -> URI.parse(url).host | |
| 216 | + _ -> nil | |
| 217 | + end | |
| 218 | + | |
| 219 | + %{ | |
| 220 | + repository: repo, | |
| 221 | + target_id: repo.id, | |
| 222 | + metadata: | |
| 223 | + Map.reject(%{visibility: repo.visibility, imported_from_host: host}, &is_nil(elem(&1, 1))) | |
| 224 | + } | |
| 225 | + end | |
| 226 | + | |
| 167 | 227 | # ── reading ────────────────────────────────────────────────────────── |
| 168 | 228 | |
| 169 | 229 | @default_limit 50 |
modified
lib/git_gud/audit/actor.ex
+7
−0
| 59 | 59 | } |
| 60 | 60 | end |
| 61 | 61 | |
| 62 | + @doc """ | |
| 63 | + A user's own request, carried out later by a background job: an | |
| 64 | + import creating the repositories they asked for. | |
| 65 | + """ | |
| 66 | + def background(%User{} = user), | |
| 67 | + do: %__MODULE__{type: "user", id: user.id, label: user.handle, via: "job"} | |
| 68 | + | |
| 62 | 69 | @doc "The address as stored: a string, from a tuple or a string." |
| 63 | 70 | def ip_string(nil), do: nil |
| 64 | 71 | def ip_string(ip) when is_binary(ip), do: ip |
modified
lib/git_gud/audit/catalog.ex
+37
−0
| 42 | 42 | target: "organization", |
| 43 | 43 | keys: ~w(from to) |
| 44 | 44 | }, |
| 45 | + # Repositories. Created, forked and deleted ones keep their | |
| 46 | + # `owner/name` as the repository label after they're gone. An | |
| 47 | + # import's source is its host only: the URL's path and userinfo stay | |
| 48 | + # out. | |
| 49 | + "repo.created" => %{ | |
| 50 | + category: "repositories", | |
| 51 | + target: "repository", | |
| 52 | + keys: ~w(visibility imported_from_host forked_from) | |
| 53 | + }, | |
| 54 | + "repo.forked" => %{category: "repositories", target: "repository", keys: ~w(destination)}, | |
| 55 | + "repo.deleted" => %{category: "repositories", target: "repository", keys: ~w()}, | |
| 56 | + "repo.branch_overwritten" => %{ | |
| 57 | + category: "repositories", | |
| 58 | + target: "branch", | |
| 59 | + keys: ~w(from to) | |
| 60 | + }, | |
| 61 | + # A settings save: each changed field as "old → new", except the | |
| 62 | + # description, which is only "changed". | |
| 63 | + "repo.settings_updated" => %{ | |
| 64 | + category: "settings", | |
| 65 | + target: "repository", | |
| 66 | + keys: ~w(description default_branch required_approvals block_merge_on_changes_requested | |
| 67 | + block_self_approval public_package_formats) | |
| 68 | + }, | |
| 69 | + "repo.visibility_changed" => %{category: "settings", target: "repository", keys: ~w(from to)}, | |
| 70 | + "repo.archived" => %{category: "settings", target: "repository", keys: ~w()}, | |
| 71 | + "repo.unarchived" => %{category: "settings", target: "repository", keys: ~w()}, | |
| 72 | + "repo.upstream_settings_changed" => %{ | |
| 73 | + category: "settings", | |
| 74 | + target: "repository", | |
| 75 | + keys: ~w(mode auto) | |
| 76 | + }, | |
| 77 | + "repo.interaction_policy_changed" => %{ | |
| 78 | + category: "settings", | |
| 79 | + target: "repository", | |
| 80 | + keys: ~w(from to) | |
| 81 | + }, | |
| 45 | 82 | # The pruner's own trace: how much went, from before when. |
| 46 | 83 | "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)} |
| 47 | 84 | } |
modified
lib/git_gud/imports.ex
+15
−2
| 26 | 26 | import Ecto.Query, warn: false |
| 27 | 27 | |
| 28 | 28 | alias GitGud.Accounts.User |
| 29 | + alias GitGud.Audit | |
| 29 | 30 | alias GitGud.Imports.Import |
| 30 | 31 | alias GitGud.Imports.ImportItem |
| 31 | 32 | alias GitGud.Imports.Provider |
| 432 | 433 | end |
| 433 | 434 | end |
| 434 | 435 | |
| 436 | + # Recorded as the import's owner, carried out by the import job. | |
| 435 | 437 | defp create(%Organization{} = org, %Import{} = import_row, attrs) do |
| 436 | 438 | creator = Repo.get!(User, import_row.owner_id) |
| 437 | − Repositories.create_repository_for_org(org, creator, attrs) | |
| 439 | + | |
| 440 | + Audit.transact( | |
| 441 | + Audit.Actor.background(creator), | |
| 442 | + "repo.created", | |
| 443 | + fn -> Repositories.create_repository_for_org(org, creator, attrs) end, | |
| 444 | + &Audit.repo_created(&1, attrs) | |
| 445 | + ) | |
| 438 | 446 | end |
| 439 | 447 | |
| 440 | 448 | defp create(%User{} = user, _import_row, attrs) do |
| 441 | − Repositories.create_repository(user, attrs) | |
| 449 | + Audit.transact( | |
| 450 | + Audit.Actor.background(user), | |
| 451 | + "repo.created", | |
| 452 | + fn -> Repositories.create_repository(user, attrs) end, | |
| 453 | + &Audit.repo_created(&1, attrs) | |
| 454 | + ) | |
| 442 | 455 | end |
| 443 | 456 | |
| 444 | 457 | @doc "The `%User{}` or `%Organization{}` this batch lands in." |
modified
lib/git_gud_web/components/audit_components.ex
+104
−20
Click to load diff…
modified
lib/git_gud_web/components/core_components.ex
+5
−0
Click to load diff…
modified
lib/git_gud_web/controllers/api/v1/org_controller.ex
+39
−10
Click to load diff…
modified
lib/git_gud_web/live/audit_live/org.ex
+13
−100
Click to load diff…
added
lib/git_gud_web/live/audit_live/page.ex
+129
−0
Click to load diff…
added
lib/git_gud_web/live/audit_live/repo.ex
+95
−0
Click to load diff…
modified
lib/git_gud_web/live/interaction_policy_live/edit.ex
+21
−1
Click to load diff…
modified
lib/git_gud_web/live/repo_live/fork.ex
+29
−1
Click to load diff…
modified
lib/git_gud_web/live/repo_live/new.ex
+14
−5
Click to load diff…
modified
lib/git_gud_web/live/repo_live/settings.ex
+92
−5
Click to load diff…
modified
lib/git_gud_web/router.ex
+5
−0
Click to load diff…
added
test/git_gud_web/live/repo_audit_test.exs
+260
−0
Click to load diff…
modified
test/git_gud_web/live/repo_live/upstream_panel_test.exs
+8
−0
Click to load diff…
Parents: 85accc3