Audit: secret and variable changes, and the org's audit page

b259852 · Gabriel Morell · 2026-10-20 21:43

26 files +688 -80
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -65,6 +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 69 - **Moderation for owners**
69 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
70 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/secrets.ex
+20 −16
@@ -247,11 +247,12 @@ defmodule GitGud.Secrets do
247 247 end
248 248
249 249 def delete_repo_secret(%Repository{id: rid}, name) do
250 − Repo.delete_all(
251 − from s in RepositorySecret, where: s.repository_id == ^rid and s.name == ^name
252 − )
250 + {count, _} =
251 + Repo.delete_all(
252 + from s in RepositorySecret, where: s.repository_id == ^rid and s.name == ^name
253 + )
253 254
254 − :ok
255 + {:ok, count}
255 256 end
256 257
257 258 # ── org secrets ──────────────────────────────────────────────────────
@@ -277,11 +278,12 @@ defmodule GitGud.Secrets do
277 278 end
278 279
279 280 def delete_org_secret(%Organization{id: oid}, name) do
280 − Repo.delete_all(
281 − from s in OrganizationSecret, where: s.organization_id == ^oid and s.name == ^name
282 − )
281 + {count, _} =
282 + Repo.delete_all(
283 + from s in OrganizationSecret, where: s.organization_id == ^oid and s.name == ^name
284 + )
283 285
284 − :ok
286 + {:ok, count}
285 287 end
286 288
287 289 # ── variables ────────────────────────────────────────────────────────
@@ -301,11 +303,12 @@ defmodule GitGud.Secrets do
301 303 end
302 304
303 305 def delete_repo_variable(%Repository{id: rid}, name) do
304 − Repo.delete_all(
305 − from v in RepositoryVariable, where: v.repository_id == ^rid and v.name == ^name
306 − )
306 + {count, _} =
307 + Repo.delete_all(
308 + from v in RepositoryVariable, where: v.repository_id == ^rid and v.name == ^name
309 + )
307 310
308 − :ok
311 + {:ok, count}
309 312 end
310 313
311 314 def list_org_variables(%Organization{id: oid}) do
@@ -323,11 +326,12 @@ defmodule GitGud.Secrets do
323 326 end
324 327
325 328 def delete_org_variable(%Organization{id: oid}, name) do
326 − Repo.delete_all(
327 − from v in OrganizationVariable, where: v.organization_id == ^oid and v.name == ^name
328 − )
329 + {count, _} =
330 + Repo.delete_all(
331 + from v in OrganizationVariable, where: v.organization_id == ^oid and v.name == ^name
332 + )
329 333
330 − :ok
334 + {:ok, count}
331 335 end
332 336
333 337 # ── resolution ───────────────────────────────────────────────────────
added lib/git_gud_web/components/audit_components.ex
+134 −0
@@ -0,0 +1,134 @@
1 +defmodule GitGudWeb.AuditComponents do
2 + @moduledoc """
3 + One audit entry as a row: when, who, what, and where.
4 +
5 + `describe/1` has a clause per `GitGud.Audit.Catalog` action; one it
6 + doesn't know yet shows as the raw action rather than breaking the
7 + page, so recording can land before its wording does.
8 + """
9 + use GitGudWeb, :html
10 +
11 + import GitGudWeb.DashboardComponents, only: [ago: 1]
12 +
13 + alias GitGud.Audit.Entry
14 +
15 + @doc "The action as a phrase, without its actor."
16 + @spec describe(Entry.t()) :: String.t()
17 + def describe(%Entry{action: action} = entry) do
18 + case action do
19 + "repo_secret.set" -> "set the secret"
20 + "repo_secret.deleted" -> "deleted the secret"
21 + "repo_variable.set" -> "set the variable"
22 + "repo_variable.deleted" -> "deleted the variable"
23 + "org_secret.set" -> "set the organization secret"
24 + "org_secret.deleted" -> "deleted the organization secret"
25 + "org_variable.set" -> "set the organization variable"
26 + "org_variable.deleted" -> "deleted the organization variable"
27 + "audit.pruned" -> "pruned #{entry.metadata["count"]} audit entries"
28 + other -> other
29 + end
30 + end
31 +
32 + @doc "Category tab label."
33 + def category_label("secrets"), do: "Secrets & variables"
34 + def category_label("members"), do: "Members"
35 + def category_label("settings"), do: "Settings"
36 + def category_label("repositories"), do: "Repositories"
37 + def category_label("branch_protection"), do: "Branch protection"
38 + def category_label("deploy_keys"), do: "Deploy keys"
39 + def category_label("webhooks"), do: "Webhooks"
40 + def category_label("credentials"), do: "Tokens"
41 + def category_label("runners"), do: "Runners"
42 + def category_label("ci"), do: "CI"
43 + def category_label("moderation"), do: "Moderation"
44 + def category_label("instance"), do: "Instance"
45 + def category_label("account"), do: "Account"
46 + def category_label(other), do: other
47 +
48 + @doc """
49 + An entry's row. `show_request` adds the client address and user
50 + agent, which only the instance view shows.
51 + """
52 + attr :id, :string, required: true
53 + attr :entry, Entry, required: true
54 + attr :show_request, :boolean, default: false
55 +
56 + def entry_row(assigns) do
57 + ~H"""
58 + <li id={@id} class="flex items-start gap-3 py-3 text-sm">
59 + <span
60 + class="w-24 shrink-0 text-xs opacity-60 tabular-nums"
61 + title={DateTime.to_iso8601(@entry.inserted_at)}
62 + >
63 + {ago(@entry.inserted_at)}
64 + </span>
65 + <div class="min-w-0 flex-1 space-y-1">
66 + <p>
67 + <.actor entry={@entry} />
68 + <span class="opacity-80">{describe(@entry)}</span>
69 + <code :if={@entry.target_label} class="font-mono text-xs bg-base-200 rounded px-1">
70 + {@entry.target_label}
71 + </code>
72 + <span :if={@entry.repository_label} class="opacity-70">
73 + in <.place entry={@entry} />
74 + </span>
75 + </p>
76 + <details :if={@entry.metadata != %{} or @show_request} class="text-xs opacity-70">
77 + <summary class="cursor-pointer select-none">Details</summary>
78 + <dl class="mt-1 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 font-mono">
79 + <%= for {key, value} <- Enum.sort(@entry.metadata) do %>
80 + <dt>{key}</dt>
81 + <dd class="break-all">{render_value(value)}</dd>
82 + <% end %>
83 + <%= if @show_request do %>
84 + <dt>ip</dt>
85 + <dd>{@entry.ip || "—"}</dd>
86 + <dt>user agent</dt>
87 + <dd class="break-all">{@entry.user_agent || "—"}</dd>
88 + <dt>via</dt>
89 + <dd>{@entry.via || "—"}</dd>
90 + <% end %>
91 + </dl>
92 + </details>
93 + </div>
94 + </li>
95 + """
96 + end
97 +
98 + attr :entry, Entry, required: true
99 +
100 + defp actor(%{entry: %Entry{actor_type: "user", actor_id: id}} = assigns) when not is_nil(id) do
101 + ~H"""
102 + <.link navigate={~p"/u/#{@entry.actor_label}"} class="font-medium link link-hover">
103 + {@entry.actor_label}
104 + </.link>
105 + """
106 + end
107 +
108 + defp actor(assigns) do
109 + ~H"""
110 + <span class="font-medium">{@entry.actor_label}</span>
111 + """
112 + end
113 +
114 + attr :entry, Entry, required: true
115 +
116 + # A link while the repository still exists; its label once it's gone.
117 + defp place(%{entry: %Entry{repository_id: id}} = assigns) when not is_nil(id) do
118 + ~H"""
119 + <.link navigate={"/r/" <> @entry.repository_label} class="link link-hover font-mono">
120 + {@entry.repository_label}
121 + </.link>
122 + """
123 + end
124 +
125 + defp place(assigns) do
126 + ~H"""
127 + <span class="font-mono line-through decoration-1">{@entry.repository_label}</span>
128 + """
129 + end
130 +
131 + defp render_value(v) when is_list(v), do: Enum.map_join(v, ", ", &to_string/1)
132 + defp render_value(nil), do: "—"
133 + defp render_value(v), do: to_string(v)
134 +end
modified lib/git_gud_web/components/core_components.ex
+7 −2
@@ -933,8 +933,8 @@ defmodule GitGudWeb.CoreComponents do
933 933 end
934 934
935 935 @doc """
936 − Same idea for org settings subpages: secrets and runners today.
937 − `current` is `:secrets | :runners`.
936 + Same idea for org settings subpages. `current` is
937 + `:secrets | :runners | :webhooks | :labels | :audit`.
938 938 """
939 939 attr :org, :map, required: true
940 940 attr :current, :atom, required: true
@@ -969,6 +969,11 @@ defmodule GitGudWeb.CoreComponents do
969 969 label="Labels"
970 970 active={@current == :labels}
971 971 />
972 + <.repo_settings_nav_link
973 + navigate={~p"/orgs/#{@org.handle}/settings/audit"}
974 + label="Audit log"
975 + active={@current == :audit}
976 + />
972 977 </nav>
973 978 </header>
974 979 """
added lib/git_gud_web/live/audit_live/filters.ex
+63 −0
@@ -0,0 +1,63 @@
1 +defmodule GitGudWeb.AuditLive.Filters do
2 + @moduledoc """
3 + The audit views' filters as path segments, and back:
4 +
5 + <base> everything, newest first
6 + <base>/<category> one category ("all" for none)
7 + <base>/<category>/before/<id> older than entry <id>
8 + <base>/<category>/after/<id> newer than entry <id>
9 +
10 + `parse/2` reads the route params; `to_path/2` writes the one
11 + canonical path for a set of filters, which the views patch to when
12 + the URL they were given spells the same filters differently.
13 + """
14 +
15 + defstruct category: nil, cursor: nil
16 +
17 + @type t :: %__MODULE__{
18 + category: String.t() | nil,
19 + cursor: {:before | :after, pos_integer()} | nil
20 + }
21 +
22 + @doc """
23 + `{:ok, filters}` from the `category` and `refine` route params, or
24 + `:error` for a category not in `categories` or a refine that isn't a
25 + cursor.
26 + """
27 + @spec parse(map(), [String.t()]) :: {:ok, t()} | :error
28 + def parse(params, categories) do
29 + with {:ok, category} <- category(params["category"], categories),
30 + {:ok, cursor} <- cursor(params["refine"]) do
31 + {:ok, %__MODULE__{category: category, cursor: cursor}}
32 + end
33 + end
34 +
35 + defp category(nil, _), do: {:ok, nil}
36 + defp category("all", _), do: {:ok, nil}
37 +
38 + defp category(c, categories) do
39 + if c in categories, do: {:ok, c}, else: :error
40 + end
41 +
42 + defp cursor(nil), do: {:ok, nil}
43 + defp cursor([]), do: {:ok, nil}
44 +
45 + defp cursor([dir, id]) when dir in ["before", "after"] do
46 + case Integer.parse(id) do
47 + {n, ""} when n > 0 -> {:ok, {String.to_existing_atom(dir), n}}
48 + _ -> :error
49 + end
50 + end
51 +
52 + defp cursor(_), do: :error
53 +
54 + @doc "The canonical path for `filters` under `base`."
55 + @spec to_path(String.t(), t()) :: String.t()
56 + def to_path(base, %__MODULE__{category: category, cursor: cursor}) do
57 + case {category, cursor} do
58 + {nil, nil} -> base
59 + {c, nil} -> "#{base}/#{c}"
60 + {c, {dir, id}} -> "#{base}/#{c || "all"}/#{dir}/#{id}"
61 + end
62 + end
63 +end
added lib/git_gud_web/live/audit_live/org.ex
+162 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/show.ex
+7 −0

Click to load diff…

modified lib/git_gud_web/live/secrets_live/org.ex
+33 −22

Click to load diff…

modified lib/git_gud_web/live/secrets_live/repo.ex
+33 −20

Click to load diff…

modified lib/git_gud_web/router.ex
+5 −0

Click to load diff…

modified test/git_gud/secrets_test.exs
+1 −1

Click to load diff…

added test/git_gud_web/live/audit_live/org_test.exs
+165 −0

Click to load diff…

modified test/git_gud_web/live/secrets_audit_log_test.exs
+57 −19

Click to load diff…

Parents: 8ccf537