neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Audit: secret and variable changes, and the org's audit page
b259852 · Gabriel Morell · 2026-10-20 21:43
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−0
| 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 | 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 | 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 | 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 | 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
| 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 | 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 | 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
| 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