Audit: each account's security log

4770085 · Gabriel Morell · 2026-10-22 18:55

33 files +635 -28
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. 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. 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 lib/git_gud/audit.ex
+14 −0
@@ -163,6 +163,20 @@ defmodule GitGud.Audit do
163 163 entry
164 164 end
165 165
166 + @doc """
167 + `record/3` for what isn't a change to roll back — a sign-in, a
168 + sign-out: an entry that can't be written is logged and dropped rather
169 + than turning the sign-in into an error.
170 + """
171 + @spec best_effort(actor(), String.t(), attrs()) :: Entry.t() | nil
172 + def best_effort(actor, action, attrs) do
173 + record(actor, action, attrs)
174 + rescue
175 + e in [DBConnection.ConnectionError, Postgrex.Error, Ecto.InvalidChangesetError] ->
176 + Logger.warning("audit: #{action} not recorded: #{Exception.message(e)}")
177 + nil
178 + end
179 +
166 180 @doc """
167 181 Insert an entry inside a transaction the caller already has open.
168 182
modified lib/git_gud/audit/catalog.ex
+20 −0
@@ -201,6 +201,26 @@ defmodule GitGud.Audit.Catalog do
201 201 # A registration invite, by id: its link is the credential.
202 202 "invite.created" => %{category: "instance", target: "invite", keys: ~w()},
203 203 "invite.revoked" => %{category: "instance", target: "invite", keys: ~w()},
204 + # A person's own account: the security log at
205 + # /users/settings/security-log. Each is about the user themselves
206 + # (the subject), with no org or repository. Never a password, code,
207 + # key body, token or email address.
208 + "session.login" => %{category: "account", target: nil, keys: ~w(method)},
209 + "session.login_failed" => %{category: "account", target: nil, keys: ~w()},
210 + "session.logout" => %{category: "account", target: nil, keys: ~w()},
211 + "sudo.entered" => %{category: "account", target: nil, keys: ~w(method)},
212 + "password.changed" => %{category: "account", target: nil, keys: ~w()},
213 + "email.changed" => %{category: "account", target: nil, keys: ~w()},
214 + "two_factor.enabled" => %{category: "account", target: nil, keys: ~w()},
215 + "two_factor.disabled" => %{category: "account", target: nil, keys: ~w()},
216 + "two_factor.reauth_changed" => %{category: "account", target: nil, keys: ~w(enabled)},
217 + "recovery_codes.regenerated" => %{category: "account", target: nil, keys: ~w()},
218 + "webauthn.added" => %{category: "account", target: "webauthn_credential", keys: ~w()},
219 + "webauthn.removed" => %{category: "account", target: "webauthn_credential", keys: ~w()},
220 + "pat.created" => %{category: "account", target: "personal_access_token", keys: ~w(scopes)},
221 + "pat.revoked" => %{category: "account", target: "personal_access_token", keys: ~w()},
222 + "ssh_key.added" => %{category: "account", target: "ssh_key", keys: ~w(fingerprint)},
223 + "ssh_key.removed" => %{category: "account", target: "ssh_key", keys: ~w(fingerprint)},
204 224 # The pruner's own trace: how much went, from before when.
205 225 "audit.pruned" => %{category: "instance", target: nil, keys: ~w(count cutoff)}
206 226 }
modified lib/git_gud/rate_limit.ex
+5 −1
@@ -44,7 +44,10 @@ defmodule GitGud.RateLimit do
44 44 # Runner registrations with a bad token.
45 45 runner_register_ip: {20, 15 * 60},
46 46 # FetchTask polls; forgejo-runner polls every 2s by default.
47 − runner_fetch: {120, 60}
47 + runner_fetch: {120, 60},
48 + # A failed sign-in reaches the account's security log once per
49 + # address per window, so a guessing run is one entry, not hundreds.
50 + login_failed_audit: {1, 15 * 60}
48 51 }
49 52
50 53 @type policy ::
@@ -55,6 +58,7 @@ defmodule GitGud.RateLimit do
55 58 | :register_ip
56 59 | :runner_register_ip
57 60 | :runner_fetch
61 + | :login_failed_audit
58 62
59 63 def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
60 64
modified lib/git_gud_web/components/audit_components.ex
+76 −1
@@ -220,6 +220,54 @@ defmodule GitGudWeb.AuditComponents do
220 220 "invite.revoked" ->
221 221 "revoked a registration invite"
222 222
223 + "session.login" ->
224 + "signed in#{method(entry.metadata)}"
225 +
226 + "session.login_failed" ->
227 + "a sign-in failed: wrong password"
228 +
229 + "session.logout" ->
230 + "signed out"
231 +
232 + "sudo.entered" ->
233 + "re-authenticated#{method(entry.metadata)}"
234 +
235 + "password.changed" ->
236 + "changed the password"
237 +
238 + "email.changed" ->
239 + "changed the email address"
240 +
241 + "two_factor.enabled" ->
242 + "turned on two-factor authentication"
243 +
244 + "two_factor.disabled" ->
245 + "turned off two-factor authentication"
246 +
247 + "two_factor.reauth_changed" ->
248 + reauth_changed(entry.metadata)
249 +
250 + "recovery_codes.regenerated" ->
251 + "generated new recovery codes"
252 +
253 + "webauthn.added" ->
254 + "added the security key"
255 +
256 + "webauthn.removed" ->
257 + "removed the security key"
258 +
259 + "pat.created" ->
260 + "created the access token (#{joined(entry.metadata["scopes"])})"
261 +
262 + "pat.revoked" ->
263 + "revoked the access token"
264 +
265 + "ssh_key.added" ->
266 + "added the SSH key"
267 +
268 + "ssh_key.removed" ->
269 + "removed the SSH key"
270 +
223 271 "audit.pruned" ->
224 272 "pruned #{entry.metadata["count"]} audit entries"
225 273
@@ -277,6 +325,22 @@ defmodule GitGudWeb.AuditComponents do
277 325 defp packages_pruned(metadata),
278 326 do: "pruned #{metadata["versions"]} package version(s), #{metadata["bytes"]} bytes"
279 327
328 + @methods %{
329 + "password" => "password",
330 + "magic_link" => "an email link",
331 + "totp" => "an authenticator code",
332 + "recovery_code" => "a recovery code",
333 + "webauthn" => "a security key"
334 + }
335 +
336 + defp method(%{"method" => m}) when is_map_key(@methods, m), do: " with " <> @methods[m]
337 + defp method(_), do: ""
338 +
339 + defp reauth_changed(%{"enabled" => true}),
340 + do: "allowed re-authenticating with a second factor"
341 +
342 + defp reauth_changed(_), do: "stopped re-authenticating with a second factor"
343 +
280 344 defp keys(metadata), do: metadata |> Map.keys() |> Enum.sort() |> Enum.join(", ")
281 345
282 346 defp from_to(metadata), do: "(#{metadata["from"]} → #{metadata["to"]})"
@@ -299,13 +363,24 @@ defmodule GitGudWeb.AuditComponents do
299 363
300 364 @doc """
301 365 An entry's row. `show_request` adds the client address and user
302 − agent, which only the instance view shows.
366 + agent, which only the instance view shows for everyone's entries;
367 + `own_requests` (a user id) shows them for that user's own entries
368 + only, as their security log does.
303 369 """
304 370 attr :id, :string, required: true
305 371 attr :entry, Entry, required: true
306 372 attr :show_request, :boolean, default: false
373 + attr :own_requests, :integer, default: nil
307 374
308 375 def entry_row(assigns) do
376 + assigns =
377 + assign(
378 + assigns,
379 + :show_request,
380 + assigns.show_request or
381 + (assigns.own_requests != nil and assigns.entry.actor_id == assigns.own_requests)
382 + )
383 +
309 384 ~H"""
310 385 <li id={@id} class="flex items-start gap-3 py-3 text-sm">
311 386 <span
modified lib/git_gud_web/components/layouts.ex
+5 −0

Click to load diff…

modified lib/git_gud_web/controllers/user_session_controller.ex
+37 −4

Click to load diff…

modified lib/git_gud_web/controllers/webauthn_controller.ex
+10 −2

Click to load diff…

modified lib/git_gud_web/live/audit_live/page.ex
+6 −0

Click to load diff…

modified lib/git_gud_web/live/personal_access_token_live/index.ex
+31 −2

Click to load diff…

modified lib/git_gud_web/live/ssh_key_live/index.ex
+27 −2

Click to load diff…

added lib/git_gud_web/live/user_live/security_log.ex
+66 −0

Click to load diff…

modified lib/git_gud_web/live/user_live/settings.ex
+6 −1

Click to load diff…

modified lib/git_gud_web/live/user_live/two_factor_challenge.ex
+3 −2

Click to load diff…

modified lib/git_gud_web/live/user_live/two_factor_setup.ex
+37 −5

Click to load diff…

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

Click to load diff…

modified lib/git_gud_web/user_auth.ex
+79 −7

Click to load diff…

added test/git_gud_web/live/user_live/security_log_test.exs
+207 −0

Click to load diff…

Parents: d859987