Audit: repositories — created, forked, reconfigured, deleted — and their own audit page

ec2ea47 · Gabriel Morell · 2026-10-22 14:22

34 files +942 -153
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` — 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,20 +76,59 @@ defmodule GitGud.Audit do
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,8 +137,8 @@ defmodule GitGud.Audit do
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, &notice/1)
103 142 original
104 143
105 144 {:error, {:returned, original}} ->
@@ -107,9 +146,9 @@ defmodule GitGud.Audit do
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,6 +203,27 @@ defmodule GitGud.Audit do
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,6 +59,13 @@ defmodule GitGud.Audit.Actor do
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,6 +42,43 @@ defmodule GitGud.Audit.Catalog do
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,6 +26,7 @@ defmodule GitGud.Imports do
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,13 +433,25 @@ defmodule GitGud.Imports do
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