Audit pages update live

b4052ae · Gabriel Morell · 2026-10-22 19:18

21 files +245 -6
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`, 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. Every account has its own **security log** at `/users/settings/security-log` (sign-ins by method, failed ones, sign-outs, re-authentication, password and email changes, two-factor, security keys, tokens, SSH keys, and what an org did about you), showing the address of your own entries only. Also over the mobile API with a token carrying `audit:read` (ticked by hand; in no bundle), and from the CLI as `gg 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. Every account has its own **security log** at `/users/settings/security-log` (sign-ins by method, failed ones, sign-outs, re-authentication, password and email changes, two-factor, security keys, tokens, SSH keys, and what an org did about you), showing the address of your own entries only. Every audit page takes new entries live, at the top of its first page. Also over the mobile API with a token carrying `audit:read` (ticked by hand; in no bundle), and from the CLI as `gg 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
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 docs/plans/org-audit-log.md
+1 −1
@@ -1,6 +1,6 @@
1 1 # Plan: an audit log for organizations, repositories and the instance
2 2
3 −Status: **ready to execute** — decisions recorded below (2026-10-20); the remaining open questions have recommended defaults.
3 +Status: **done** — slices 1–12 shipped 2026-10-20 to 2026-10-22. Open questions 4 and 7–14 went with their recommended defaults: rows outlive their actor; an instance admin reading an org's log leaves no entry; bans are recorded twice; no-ops are skipped; refusals aren't recorded; append-only isn't enforced in the database; `audit:read` is in no bundle; `gg audit` has no Android screen (cli/README.md says why); no export or forwarding.
4 4 Independent of the CI plans. Supersedes the log lines added in 77def2d
5 5 ("Secrets: changing a secret or variable leaves a line in the log") and
6 6 closes item 12 of `security-review-ci.md` ("Missing audit logging for
modified lib/git_gud/audit.ex
+44 −1
@@ -200,9 +200,49 @@ defmodule GitGud.Audit do
200 200 end)
201 201 end
202 202
203 − @doc "The log line for a committed entry."
203 + @doc """
204 + What to do with an entry once it has committed: its log line, and a
205 + `{:audit, id}` broadcast to each log it belongs in (`topics/1`), for
206 + the audit pages open on them.
207 + """
204 208 @spec notice(Entry.t()) :: :ok
205 209 def notice(%Entry{} = entry) do
210 + log_line(entry)
211 +
212 + for topic <- topics(entry),
213 + do: Phoenix.PubSub.broadcast(GitGud.PubSub, topic, {:audit, entry.id})
214 +
215 + :ok
216 + end
217 +
218 + @doc """
219 + The PubSub topics of the logs `entry` shows in: the instance's
220 + always, its org's and repository's, and the account log of the
221 + person it's about — or of whoever did it, for an entry about no org
222 + or repository.
223 + """
224 + @spec topics(Entry.t()) :: [String.t()]
225 + def topics(%Entry{} = e) do
226 + user =
227 + e.subject_user_id ||
228 + if(is_nil(e.organization_id) and is_nil(e.repository_id), do: e.actor_id)
229 +
230 + [
231 + "audit:instance",
232 + e.organization_id && "audit:org:#{e.organization_id}",
233 + e.repository_id && "audit:repo:#{e.repository_id}",
234 + user && "audit:user:#{user}"
235 + ]
236 + |> Enum.reject(&is_nil/1)
237 + end
238 +
239 + @doc "The topic `notice/1` broadcasts `scope`'s new entries on."
240 + def topic(:instance), do: "audit:instance"
241 + def topic({:org, %Organization{id: id}}), do: "audit:org:#{id}"
242 + def topic({:repo, %Repository{id: id}}), do: "audit:repo:#{id}"
243 + def topic({:user, %User{id: id}}), do: "audit:user:#{id}"
244 +
245 + defp log_line(%Entry{} = entry) do
206 246 where =
207 247 [entry.target_label, entry.repository_label || entry.organization_label]
208 248 |> Enum.reject(&is_nil/1)
@@ -431,6 +471,9 @@ defmodule GitGud.Audit do
431 471 {:organization_id, id}, q when is_integer(id) ->
432 472 from(e in q, where: e.organization_id == ^id)
433 473
474 + {:id, id}, q when is_integer(id) ->
475 + from(e in q, where: e.id == ^id)
476 +
434 477 # A deleted account's or repository's entries, by the label
435 478 # they were recorded under.
436 479 {:actor_label, label}, q when is_binary(label) ->
modified lib/git_gud_web/live/audit_live/admin.ex
+13 −1
@@ -26,7 +26,7 @@ defmodule GitGudWeb.AuditLive.Admin do
26 26 @impl true
27 27 def handle_params(params, uri, socket) do
28 28 # `:require_admin` ran at mount; a live patch doesn't run it again.
29 − if socket.assigns.current_scope.user.is_admin,
29 + if admin_now?(socket),
30 30 do: Page.params(socket, params, uri, ~p"/admin/audit", :instance),
31 31 else: {:noreply, socket |> put_flash(:error, "Admins only.") |> redirect(to: ~p"/")}
32 32 end
@@ -35,6 +35,18 @@ defmodule GitGudWeb.AuditLive.Admin do
35 35 def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params)
36 36 def handle_event(_event, _params, socket), do: {:noreply, socket}
37 37
38 + @impl true
39 + def handle_info({:audit, id}, socket), do: Page.live(socket, id, admin_now?(socket))
40 +
41 + # Read again rather than trusting the mount's copy of the account: a
42 + # page can outlive its viewer's admin role.
43 + defp admin_now?(socket) do
44 + case GitGud.Repo.get(GitGud.Accounts.User, socket.assigns.current_scope.user.id) do
45 + %{is_admin: true} -> true
46 + _ -> false
47 + end
48 + end
49 +
38 50 @impl true
39 51 def render(assigns) do
40 52 ~H"""
modified lib/git_gud_web/live/audit_live/org.ex
+6 −0
@@ -49,6 +49,12 @@ defmodule GitGudWeb.AuditLive.Org do
49 49 def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params)
50 50 def handle_event(_event, _params, socket), do: {:noreply, socket}
51 51
52 + @impl true
53 + def handle_info({:audit, id}, socket) do
54 + %{org: org, current_scope: %{user: user}} = socket.assigns
55 + Page.live(socket, id, allowed?(org, user))
56 + end
57 +
52 58 # Every repository the org has, including any gone since: a deleted
53 59 # one's entries are still in the log, under its old name.
54 60 defp repo_names(org) do
modified lib/git_gud_web/live/audit_live/page.ex
+52 −2
@@ -5,6 +5,11 @@ defmodule GitGudWeb.AuditLive.Page do
5 5 tabs, the actor and repository filter, the entries, and Older/Newer
6 6 links.
7 7
8 + New entries arrive live: once connected, a view subscribes to its
9 + scope's topic (`GitGud.Audit.topic/1`), and `live/3` puts each one
10 + that matches the filters at the top of the first page. Older pages
11 + stay as they were.
12 +
8 13 An actor filter names a handle: the account's entries if it exists,
9 14 and otherwise the ones recorded under that label, so a deleted
10 15 account's history can still be picked out. A repository filter does
@@ -17,7 +22,9 @@ defmodule GitGudWeb.AuditLive.Page do
17 22 use GitGudWeb, :html
18 23
19 24 import GitGudWeb.AuditComponents
20 − import Phoenix.LiveView, only: [put_flash: 3, redirect: 2, push_patch: 2, stream: 4]
25 +
26 + import Phoenix.LiveView,
27 + only: [put_flash: 3, redirect: 2, push_patch: 2, stream: 4, stream_insert: 4, connected?: 1]
21 28
22 29 alias GitGud.Accounts
23 30 alias GitGud.Audit
@@ -40,7 +47,7 @@ defmodule GitGudWeb.AuditLive.Page do
40 47 canonical = Filters.to_path(base, filters)
41 48
42 49 if path == canonical,
43 − do: {:noreply, load(socket, base, scope, filters)},
50 + do: {:noreply, socket |> subscribe(scope) |> load(base, scope, filters)},
44 51 else: {:noreply, push_patch(socket, to: canonical, replace: true)}
45 52 else
46 53 :reauth ->
@@ -82,6 +89,48 @@ defmodule GitGudWeb.AuditLive.Page do
82 89 |> Phoenix.LiveView.stream_configure(:entries, dom_id: &"audit-entry-#{&1.id}")
83 90 end
84 91
92 + @doc """
93 + A `{:audit, id}` broadcast arrived. `allowed?` is the view's own
94 + permission check, asked again — a page left open mustn't keep
95 + showing new entries to someone who has since lost the right, or
96 + whose sudo window closed. Then, on the first page only, the entry
97 + goes at the top if it matches the filters.
98 + """
99 + def live(socket, id, allowed?) do
100 + user = socket.assigns.current_scope.user
101 +
102 + cond do
103 + not (allowed? and Accounts.sudo_mode?(user)) ->
104 + {:noreply, redirect(socket, to: socket.assigns.base)}
105 +
106 + socket.assigns.filters.cursor != nil ->
107 + {:noreply, socket}
108 +
109 + true ->
110 + %{scope: scope, filters: filters} = socket.assigns
111 + matching = Map.put(list_filters(scope, filters), :id, id)
112 +
113 + case Audit.list(scope, matching, nil, 1) do
114 + {[entry], _} ->
115 + {:noreply, stream_insert(socket, :entries, entry, at: 0, limit: @per_page)}
116 +
117 + _ ->
118 + {:noreply, socket}
119 + end
120 + end
121 + end
122 +
123 + defp subscribe(%{assigns: %{subscribed: true}} = socket, _scope), do: socket
124 +
125 + defp subscribe(socket, scope) do
126 + if connected?(socket) do
127 + Phoenix.PubSub.subscribe(GitGud.PubSub, Audit.topic(scope))
128 + assign(socket, :subscribed, true)
129 + else
130 + socket
131 + end
132 + end
133 +
85 134 @doc "The filter form's submit: patch to the path for what it says."
86 135 def filter(socket, params) do
87 136 case Filters.from_form(socket.assigns.filters, params) do
@@ -99,6 +148,7 @@ defmodule GitGudWeb.AuditLive.Page do
99 148
100 149 socket
101 150 |> assign(:base, base)
151 + |> assign(:scope, scope)
102 152 |> assign(:filters, filters)
103 153 |> assign(
104 154 :filter_form,
modified lib/git_gud_web/live/audit_live/repo.ex
+7 −0
@@ -62,6 +62,13 @@ defmodule GitGudWeb.AuditLive.Repo do
62 62 def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params)
63 63 def handle_event(_event, _params, socket), do: {:noreply, socket}
64 64
65 + @impl true
66 + def handle_info({:audit, id}, socket) do
67 + %{repo: repo, current_scope: %{user: user}} = socket.assigns
68 + fresh = GitGud.Repo.get(GitGud.Repositories.Repository, repo.id)
69 + Page.live(socket, id, fresh != nil and allowed?(fresh, user))
70 + end
71 +
65 72 defp allowed?(repo, user), do: GitGud.Audit.readable?(user, {:repo, repo})
66 73
67 74 @impl true
modified lib/git_gud_web/live/user_live/security_log.ex
+3 −0
@@ -35,6 +35,9 @@ defmodule GitGudWeb.UserLive.SecurityLog do
35 35 Page.params(socket, params, uri, ~p"/users/settings/security-log", {:user, user})
36 36 end
37 37
38 + @impl true
39 + def handle_info({:audit, id}, socket), do: Page.live(socket, id, true)
40 +
38 41 @impl true
39 42 def render(assigns) do
40 43 ~H"""
added test/git_gud_web/live/audit_live/live_updates_test.exs
+118 −0
@@ -0,0 +1,118 @@
1 +defmodule GitGudWeb.AuditLive.LiveUpdatesTest do
2 + @moduledoc """
3 + New entries arrive on an open audit page: at the top of the first
4 + page, when they match its scope and filters — and not for someone who
5 + lost the right to see them while the page was open.
6 + """
7 + use GitGudWeb.ConnCase, async: true
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.AccountsFixtures
11 +
12 + alias GitGud.Accounts.Scope
13 + alias GitGud.Audit
14 + alias GitGud.Organizations
15 +
16 + setup %{conn: conn} do
17 + admin = user_fixture()
18 + handle = "lu#{System.unique_integer([:positive])}"
19 + {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle, "name" => handle})
20 +
21 + {:ok,
22 + admin: admin,
23 + org: org,
24 + conn: log_in_user(conn, admin),
25 + path: "/orgs/#{handle}/settings/audit"}
26 + end
27 +
28 + defp put_secret(org, user, name),
29 + do:
30 + Audit.record(Scope.for_user(user), "org_secret.set", %{
31 + organization: org,
32 + target_label: name
33 + })
34 +
35 + test "lands at the top of an open page", %{conn: conn, org: org, admin: admin, path: path} do
36 + {:ok, view, _} = live(conn, path)
37 + assert has_element?(view, "#audit-empty")
38 +
39 + entry = put_secret(org, admin, "LIVE")
40 +
41 + assert has_element?(view, "#audit-entry-#{entry.id}", "LIVE")
42 + end
43 +
44 + test "only what matches the scope and the filters", %{
45 + conn: conn,
46 + org: org,
47 + admin: admin,
48 + path: path
49 + } do
50 + other_owner = user_fixture()
51 +
52 + {:ok, other} =
53 + Organizations.create_organization(other_owner, %{
54 + "handle" => "o#{System.unique_integer([:positive])}",
55 + "name" => "o"
56 + })
57 +
58 + {:ok, view, _} = live(conn, path <> "/members")
59 +
60 + secret = put_secret(org, admin, "NOT-A-MEMBER-THING")
61 + elsewhere = put_secret(other, other_owner, "ELSEWHERE")
62 +
63 + member = user_fixture()
64 +
65 + invited =
66 + Audit.record(Scope.for_user(admin), "org.invitation_sent", %{
67 + organization: org,
68 + subject: member,
69 + target_label: member.handle
70 + })
71 +
72 + assert has_element?(view, "#audit-entry-#{invited.id}")
73 + refute has_element?(view, "#audit-entry-#{secret.id}")
74 + refute has_element?(view, "#audit-entry-#{elsewhere.id}")
75 + end
76 +
77 + test "an older page stays as it was", %{conn: conn, org: org, admin: admin, path: path} do
78 + first = put_secret(org, admin, "FIRST")
79 + {:ok, view, _} = live(conn, path <> "/all/before/#{first.id + 1}")
80 +
81 + later = put_secret(org, admin, "LATER")
82 + assert has_element?(view, "#audit-entry-#{first.id}")
83 + refute has_element?(view, "#audit-entry-#{later.id}")
84 + end
85 +
86 + test "a demoted admin's open page stops, rather than showing more", %{
87 + conn: conn,
88 + org: org,
89 + admin: admin,
90 + path: path
91 + } do
92 + co_admin = user_fixture()
93 + {:ok, _} = Organizations.add_member(org, co_admin, "admin")
94 + {:ok, view, _} = live(log_in_user(conn, co_admin), path)
95 +
96 + {:ok, _} = Organizations.set_member_role(org, co_admin, "member")
97 + put_secret(org, admin, "AFTER")
98 +
99 + assert_redirect(view, path)
100 + end
101 +
102 + test "your own security log", %{conn: conn, admin: admin} do
103 + {:ok, view, _} = live(conn, ~p"/users/settings/security-log")
104 +
105 + entry =
106 + Audit.record(Scope.for_user(admin), "pat.created", %{subject: admin, target_label: "cli"})
107 +
108 + assert has_element?(view, "#audit-entry-#{entry.id}", "cli")
109 + end
110 +
111 + test "each entry goes to the logs it belongs in", %{org: org, admin: admin} do
112 + in_org = put_secret(org, admin, "X")
113 + mine = Audit.record(Scope.for_user(admin), "session.logout", %{subject: admin})
114 +
115 + assert Audit.topics(in_org) == ["audit:instance", "audit:org:#{org.id}"]
116 + assert Audit.topics(mine) == ["audit:instance", "audit:user:#{admin.id}"]
117 + end
118 +end

Parents: 4770085