neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Audit: each account's security log
4770085 · Gabriel Morell · 2026-10-22 18:55
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−1
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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