neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Audit pages update live
b4052ae · Gabriel Morell · 2026-10-22 19:18
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