neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Audit pages: filter by who and by repository
ba66a27 · Gabriel Morell · 2026-10-22 17:44
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`, and per repository at `/r/:owner/:name/settings/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 (`…/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 | + - **Audit log** at `/orgs/:handle/settings/audit`, and per repository at `/r/:owner/:name/settings/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
+27
−1
@@ -256,7 +256,9 @@ defmodule GitGud.Audit do
| 256 | 256 | `{:repo, repo}`, `:instance` (everything) or `{:user, user}` (what was |
| 257 | 257 | done to them, and what they did outside any org or repository). |
| 258 | 258 | |
| 259 | − Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`. | |
| 259 | + Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`, | |
| 260 | + and `:actor_label` / `:repository_label` for entries whose account or | |
| 261 | + repository is gone. | |
| 260 | 262 | `cursor` is nil, `{:before, id}` (older) or `{:after, id}` (newer). |
| 261 | 263 | Returns `{entries, %{older: id | nil, newer: id | nil}}`: the cursors |
| 262 | 264 | for the next page in each direction, nil when there's none. |
@@ -311,6 +313,22 @@ defmodule GitGud.Audit do
| 311 | 313 | {rows, cursors} |
| 312 | 314 | end |
| 313 | 315 | |
| 316 | + @doc """ | |
| 317 | + The names of `org`'s repositories that are gone but still have | |
| 318 | + entries, recorded under their old `handle/name`. | |
| 319 | + """ | |
| 320 | + @spec deleted_repository_names(Organization.t()) :: [String.t()] | |
| 321 | + def deleted_repository_names(%Organization{id: id}) do | |
| 322 | + from(e in Entry, | |
| 323 | + where: e.organization_id == ^id and is_nil(e.repository_id), | |
| 324 | + where: not is_nil(e.repository_label), | |
| 325 | + distinct: true, | |
| 326 | + select: e.repository_label | |
| 327 | + ) | |
| 328 | + |> Repo.all() | |
| 329 | + |> Enum.map(&(&1 |> String.split("/", parts: 2) |> List.last())) | |
| 330 | + end | |
| 331 | + | |
| 314 | 332 | defp scoped(query, {:org, %Organization{id: id}}), |
| 315 | 333 | do: from(e in query, where: e.organization_id == ^id) |
| 316 | 334 |
@@ -341,6 +359,14 @@ defmodule GitGud.Audit do
| 341 | 359 | {:organization_id, id}, q when is_integer(id) -> |
| 342 | 360 | from(e in q, where: e.organization_id == ^id) |
| 343 | 361 | |
| 362 | + # A deleted account's or repository's entries, by the label | |
| 363 | + # they were recorded under. | |
| 364 | + {:actor_label, label}, q when is_binary(label) -> | |
| 365 | + from(e in q, where: is_nil(e.actor_id) and e.actor_label == ^label) | |
| 366 | + | |
| 367 | + {:repository_label, label}, q when is_binary(label) -> | |
| 368 | + from(e in q, where: is_nil(e.repository_id) and e.repository_label == ^label) | |
| 369 | + | |
| 344 | 370 | _, q -> |
| 345 | 371 | q |
| 346 | 372 | end) |
modified
lib/git_gud_web/live/audit_live/filters.ex
+85
−26
@@ -2,33 +2,45 @@ defmodule GitGudWeb.AuditLive.Filters do
| 2 | 2 | @moduledoc """ |
| 3 | 3 | The audit views' filters as path segments, and back: |
| 4 | 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. | |
| 5 | + <base> everything, newest first | |
| 6 | + <base>/<category> one category ("all" for none) | |
| 7 | + <base>/<category>/actor/<handle> what one person (or runner) did | |
| 8 | + <base>/<category>/repo/<name> one of the org's repositories | |
| 9 | + <base>/<category>/before/<id> older than entry <id> | |
| 10 | + <base>/<category>/after/<id> newer than entry <id> | |
| 11 | + | |
| 12 | + The refinements combine, e.g. | |
| 13 | + `…/audit/secrets/actor/alice/repo/web/before/12345`. `parse/3` reads | |
| 14 | + the route params and accepts them in any order; `to_path/2` writes | |
| 15 | + the one canonical path — category, actor, repo, cursor — which the | |
| 16 | + views patch to when the URL they were given spells the same filters | |
| 17 | + differently. | |
| 13 | 18 | """ |
| 14 | 19 | |
| 15 | − defstruct category: nil, cursor: nil | |
| 20 | + defstruct category: nil, actor: nil, repo: nil, cursor: nil | |
| 16 | 21 | |
| 17 | 22 | @type t :: %__MODULE__{ |
| 18 | 23 | category: String.t() | nil, |
| 24 | + actor: String.t() | nil, | |
| 25 | + repo: String.t() | nil, | |
| 19 | 26 | cursor: {:before | :after, pos_integer()} | nil |
| 20 | 27 | } |
| 21 | 28 | |
| 29 | + # Handles and repository names as the forge makes them; anything else | |
| 30 | + # in a path segment is a typo or a probe, and gets the canonical path. | |
| 31 | + @name ~r/\A[A-Za-z0-9][A-Za-z0-9_.\-]{0,99}\z/ | |
| 32 | + | |
| 22 | 33 | @doc """ |
| 23 | 34 | `{: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. | |
| 35 | + `:error` for a category not in `categories`, a refinement this view | |
| 36 | + doesn't offer (`refines`, of `:actor` and `:repo`), a repeated one, | |
| 37 | + or a malformed value. | |
| 26 | 38 | """ |
| 27 | − @spec parse(map(), [String.t()]) :: {:ok, t()} | :error | |
| 28 | − def parse(params, categories) do | |
| 39 | + @spec parse(map(), [String.t()], [:actor | :repo]) :: {:ok, t()} | :error | |
| 40 | + def parse(params, categories, refines \\ [:actor, :repo]) do | |
| 29 | 41 | with {:ok, category} <- category(params["category"], categories), |
| 30 | − {:ok, cursor} <- cursor(params["refine"]) do | |
| 31 | − {:ok, %__MODULE__{category: category, cursor: cursor}} | |
| 42 | + {:ok, filters} <- refine(params["refine"] || [], refines, %__MODULE__{}) do | |
| 43 | + {:ok, %{filters | category: category}} | |
| 32 | 44 | end |
| 33 | 45 | end |
| 34 | 46 |
@@ -39,25 +51,72 @@ defmodule GitGudWeb.AuditLive.Filters do
| 39 | 51 | if c in categories, do: {:ok, c}, else: :error |
| 40 | 52 | end |
| 41 | 53 | |
| 42 | − defp cursor(nil), do: {:ok, nil} | |
| 43 | − defp cursor([]), do: {:ok, nil} | |
| 54 | + defp refine([], _refines, filters), do: {:ok, filters} | |
| 55 | + | |
| 56 | + defp refine([key, value | rest], refines, filters) when key in ["actor", "repo"] do | |
| 57 | + field = String.to_existing_atom(key) | |
| 58 | + | |
| 59 | + if field in refines and Map.get(filters, field) == nil and Regex.match?(@name, value), | |
| 60 | + do: refine(rest, refines, Map.put(filters, field, value)), | |
| 61 | + else: :error | |
| 62 | + end | |
| 44 | 63 | |
| 45 | − defp cursor([dir, id]) when dir in ["before", "after"] do | |
| 64 | + defp refine([dir, id | rest], refines, %{cursor: nil} = filters) | |
| 65 | + when dir in ["before", "after"] do | |
| 46 | 66 | case Integer.parse(id) do |
| 47 | − {n, ""} when n > 0 -> {:ok, {String.to_existing_atom(dir), n}} | |
| 48 | − _ -> :error | |
| 67 | + {n, ""} when n > 0 -> | |
| 68 | + refine(rest, refines, %{filters | cursor: {String.to_existing_atom(dir), n}}) | |
| 69 | + | |
| 70 | + _ -> | |
| 71 | + :error | |
| 49 | 72 | end |
| 50 | 73 | end |
| 51 | 74 | |
| 52 | − defp cursor(_), do: :error | |
| 75 | + defp refine(_, _, _), do: :error | |
| 53 | 76 | |
| 54 | 77 | @doc "The canonical path for `filters` under `base`." |
| 55 | 78 | @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}" | |
| 79 | + def to_path(base, %__MODULE__{} = filters) do | |
| 80 | + refinements = | |
| 81 | + [ | |
| 82 | + filters.actor && ["actor", filters.actor], | |
| 83 | + filters.repo && ["repo", filters.repo], | |
| 84 | + case filters.cursor do | |
| 85 | + {dir, id} -> [Atom.to_string(dir), Integer.to_string(id)] | |
| 86 | + nil -> nil | |
| 87 | + end | |
| 88 | + ] | |
| 89 | + |> Enum.reject(&is_nil/1) | |
| 90 | + |> List.flatten() | |
| 91 | + | |
| 92 | + case {filters.category, refinements} do | |
| 93 | + {nil, []} -> base | |
| 94 | + {c, []} -> "#{base}/#{c}" | |
| 95 | + {c, segments} -> Enum.join([base, c || "all" | segments], "/") | |
| 61 | 96 | end |
| 62 | 97 | end |
| 98 | + | |
| 99 | + @doc """ | |
| 100 | + Filters from the filter form's fields: blank means any, and the | |
| 101 | + cursor is dropped since the result is a different list. `:error` for | |
| 102 | + a value no path could carry. | |
| 103 | + """ | |
| 104 | + @spec from_form(t(), map()) :: {:ok, t()} | :error | |
| 105 | + def from_form(%__MODULE__{} = filters, params) do | |
| 106 | + with {:ok, actor} <- form_value(params["actor"]), | |
| 107 | + {:ok, repo} <- form_value(params["repo"]) do | |
| 108 | + {:ok, %{filters | actor: actor, repo: repo, cursor: nil}} | |
| 109 | + end | |
| 110 | + end | |
| 111 | + | |
| 112 | + defp form_value(nil), do: {:ok, nil} | |
| 113 | + | |
| 114 | + defp form_value(value) when is_binary(value) do | |
| 115 | + case value |> String.trim() |> String.trim_leading("@") do | |
| 116 | + "" -> {:ok, nil} | |
| 117 | + v -> if Regex.match?(@name, v), do: {:ok, v}, else: :error | |
| 118 | + end | |
| 119 | + end | |
| 120 | + | |
| 121 | + defp form_value(_), do: :error | |
| 63 | 122 | end |
modified
lib/git_gud_web/live/audit_live/org.ex
+15
−1
@@ -30,7 +30,7 @@ defmodule GitGudWeb.AuditLive.Org do
| 30 | 30 | socket |
| 31 | 31 | |> assign(:org, org) |
| 32 | 32 | |> assign(:page_title, "Audit log — #{handle}") |
| 33 | − |> Page.setup(@categories)} | |
| 33 | + |> Page.setup(@categories, repos: repo_names(org))} | |
| 34 | 34 | else |
| 35 | 35 | {:ok, deny(socket, org)} |
| 36 | 36 | end |
@@ -45,6 +45,18 @@ defmodule GitGudWeb.AuditLive.Org do
| 45 | 45 | else: {:noreply, deny(socket, org)} |
| 46 | 46 | end |
| 47 | 47 | |
| 48 | + @impl true | |
| 49 | + def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params) | |
| 50 | + def handle_event(_event, _params, socket), do: {:noreply, socket} | |
| 51 | + | |
| 52 | + # Every repository the org has, including any gone since: a deleted | |
| 53 | + # one's entries are still in the log, under its old name. | |
| 54 | + defp repo_names(org) do | |
| 55 | + live = GitGud.Repositories.list_repositories_for(org) |> Enum.map(& &1.name) | |
| 56 | + | |
| 57 | + (live ++ GitGud.Audit.deleted_repository_names(org)) |> Enum.uniq() |> Enum.sort() | |
| 58 | + end | |
| 59 | + | |
| 48 | 60 | defp allowed?(_org, nil), do: false |
| 49 | 61 | defp allowed?(org, user), do: user.is_admin or Organizations.org_admin?(org, user) |
| 50 | 62 |
@@ -63,7 +75,9 @@ defmodule GitGudWeb.AuditLive.Org do
| 63 | 75 | <Page.body |
| 64 | 76 | base={@base} |
| 65 | 77 | filters={@filters} |
| 78 | + filter_form={@filter_form} | |
| 66 | 79 | categories={@categories} |
| 80 | + repo_options={@repo_options} | |
| 67 | 81 | entries={@streams.entries} |
| 68 | 82 | older={@older} |
| 69 | 83 | newer={@newer} |
modified
lib/git_gud_web/live/audit_live/page.ex
+104
−9
@@ -2,7 +2,13 @@ defmodule GitGudWeb.AuditLive.Page do
| 2 | 2 | @moduledoc """ |
| 3 | 3 | What the audit views share: the sudo check and canonical paths in |
| 4 | 4 | `handle_params`, a page of entries, and the page body — category |
| 5 | − tabs, the entries, and Older/Newer links. | |
| 5 | + tabs, the actor and repository filter, the entries, and Older/Newer | |
| 6 | + links. | |
| 7 | + | |
| 8 | + An actor filter names a handle: the account's entries if it exists, | |
| 9 | + and otherwise the ones recorded under that label, so a deleted | |
| 10 | + account's history can still be picked out. A repository filter does | |
| 11 | + the same with the org's `handle/name`. | |
| 6 | 12 | |
| 7 | 13 | Each view keeps its own permission check (run on every |
| 8 | 14 | `handle_params`, before `params/5`) and its own chrome around |
@@ -29,7 +35,8 @@ defmodule GitGudWeb.AuditLive.Page do
| 29 | 35 | path = URI.parse(uri).path |
| 30 | 36 | |
| 31 | 37 | with true <- Accounts.sudo_mode?(socket.assigns.current_scope.user) || :reauth, |
| 32 | − {:ok, filters} <- Filters.parse(params, socket.assigns.categories) do | |
| 38 | + {:ok, filters} <- | |
| 39 | + Filters.parse(params, socket.assigns.categories, socket.assigns.refines) do | |
| 33 | 40 | canonical = Filters.to_path(base, filters) |
| 34 | 41 | |
| 35 | 42 | if path == canonical, |
@@ -53,20 +60,44 @@ defmodule GitGudWeb.AuditLive.Page do
| 53 | 60 | @doc "The categories, of `wanted`, that some catalog action belongs to." |
| 54 | 61 | def categories(wanted), do: Enum.filter(wanted, &(&1 in GitGud.Audit.Catalog.categories())) |
| 55 | 62 | |
| 56 | − @doc "Ready a mounted socket for `params/5`: the categories and the stream." | |
| 57 | − def setup(socket, categories) do | |
| 63 | + @doc """ | |
| 64 | + Ready a mounted socket for `params/5`: the categories, which | |
| 65 | + refinements the view offers, and the stream. `repos:` lists the | |
| 66 | + repository names the filter offers, for a view that filters by | |
| 67 | + repository. | |
| 68 | + """ | |
| 69 | + def setup(socket, categories, opts \\ []) do | |
| 70 | + repos = Keyword.get(opts, :repos) | |
| 71 | + | |
| 58 | 72 | socket |
| 59 | 73 | |> assign(:categories, categories(categories)) |
| 74 | + |> assign(:refines, if(repos, do: [:actor, :repo], else: [:actor])) | |
| 75 | + |> assign(:repo_options, repos) | |
| 60 | 76 | |> Phoenix.LiveView.stream_configure(:entries, dom_id: &"audit-entry-#{&1.id}") |
| 61 | 77 | end |
| 62 | 78 | |
| 79 | + @doc "The filter form's submit: patch to the path for what it says." | |
| 80 | + def filter(socket, params) do | |
| 81 | + case Filters.from_form(socket.assigns.filters, params) do | |
| 82 | + {:ok, filters} -> | |
| 83 | + {:noreply, push_patch(socket, to: Filters.to_path(socket.assigns.base, filters))} | |
| 84 | + | |
| 85 | + :error -> | |
| 86 | + {:noreply, put_flash(socket, :error, "That isn't a handle or repository name.")} | |
| 87 | + end | |
| 88 | + end | |
| 89 | + | |
| 63 | 90 | defp load(socket, base, scope, filters) do |
| 64 | 91 | {entries, cursors} = |
| 65 | − Audit.list(scope, %{category: filters.category}, filters.cursor, @per_page) | |
| 92 | + Audit.list(scope, list_filters(scope, filters), filters.cursor, @per_page) | |
| 66 | 93 | |
| 67 | 94 | socket |
| 68 | 95 | |> assign(:base, base) |
| 69 | 96 | |> assign(:filters, filters) |
| 97 | + |> assign( | |
| 98 | + :filter_form, | |
| 99 | + to_form(%{"actor" => filters.actor || "", "repo" => filters.repo || ""}, as: :filter) | |
| 100 | + ) | |
| 70 | 101 | |> assign( |
| 71 | 102 | :older, |
| 72 | 103 | cursors.older && Filters.to_path(base, %{filters | cursor: {:before, cursors.older}}) |
@@ -75,15 +106,43 @@ defmodule GitGudWeb.AuditLive.Page do
| 75 | 106 | |> stream(:entries, entries, reset: true) |
| 76 | 107 | end |
| 77 | 108 | |
| 109 | + defp list_filters(scope, filters) do | |
| 110 | + %{category: filters.category} | |
| 111 | + |> Map.merge(actor_filter(filters.actor)) | |
| 112 | + |> Map.merge(repo_filter(scope, filters.repo)) | |
| 113 | + end | |
| 114 | + | |
| 115 | + defp actor_filter(nil), do: %{} | |
| 116 | + | |
| 117 | + defp actor_filter(handle) do | |
| 118 | + case Accounts.get_user_by_handle(handle) do | |
| 119 | + %{id: id} -> %{actor_id: id} | |
| 120 | + nil -> %{actor_label: handle} | |
| 121 | + end | |
| 122 | + end | |
| 123 | + | |
| 124 | + defp repo_filter(_scope, nil), do: %{} | |
| 125 | + | |
| 126 | + defp repo_filter({:org, org}, name) do | |
| 127 | + case GitGud.Repo.get_by(GitGud.Repositories.Repository, organization_id: org.id, name: name) do | |
| 128 | + %{id: id} -> %{repository_id: id} | |
| 129 | + nil -> %{repository_label: "#{org.handle}/#{name}"} | |
| 130 | + end | |
| 131 | + end | |
| 132 | + | |
| 133 | + defp repo_filter(_scope, _name), do: %{} | |
| 134 | + | |
| 78 | 135 | # Back to the top when the newer page would be the first one anyway. |
| 79 | 136 | defp newer_path(_base, %Filters{cursor: nil}, _), do: nil |
| 80 | 137 | defp newer_path(base, filters, nil), do: Filters.to_path(base, %{filters | cursor: nil}) |
| 81 | 138 | defp newer_path(base, filters, id), do: Filters.to_path(base, %{filters | cursor: {:after, id}}) |
| 82 | 139 | |
| 83 | − @doc "Category tabs, the entries, and the pager." | |
| 140 | + @doc "Category tabs, the filter, the entries, and the pager." | |
| 84 | 141 | attr :base, :string, required: true |
| 85 | 142 | attr :filters, Filters, required: true |
| 143 | + attr :filter_form, Phoenix.HTML.Form, required: true | |
| 86 | 144 | attr :categories, :list, required: true |
| 145 | + attr :repo_options, :list, default: nil | |
| 87 | 146 | attr :entries, :any, required: true |
| 88 | 147 | attr :older, :string, default: nil |
| 89 | 148 | attr :newer, :string, default: nil |
@@ -93,7 +152,7 @@ defmodule GitGudWeb.AuditLive.Page do
| 93 | 152 | <nav id="audit-categories" class="flex flex-wrap gap-1" aria-label="Category"> |
| 94 | 153 | <.link |
| 95 | 154 | id="audit-category-all" |
| 96 | − patch={@base} | |
| 155 | + patch={tab(@base, @filters, nil)} | |
| 97 | 156 | class={["btn btn-xs", if(@filters.category == nil, do: "btn-primary", else: "btn-ghost")]} |
| 98 | 157 | > |
| 99 | 158 | All |
@@ -101,16 +160,47 @@ defmodule GitGudWeb.AuditLive.Page do
| 101 | 160 | <.link |
| 102 | 161 | :for={c <- @categories} |
| 103 | 162 | id={"audit-category-#{c}"} |
| 104 | − patch={"#{@base}/#{c}"} | |
| 163 | + patch={tab(@base, @filters, c)} | |
| 105 | 164 | class={["btn btn-xs", if(@filters.category == c, do: "btn-primary", else: "btn-ghost")]} |
| 106 | 165 | > |
| 107 | 166 | {category_label(c)} |
| 108 | 167 | </.link> |
| 109 | 168 | </nav> |
| 110 | 169 | |
| 170 | + <.form | |
| 171 | + for={@filter_form} | |
| 172 | + id="audit-filter" | |
| 173 | + phx-submit="filter" | |
| 174 | + class="flex flex-wrap items-end gap-2" | |
| 175 | + > | |
| 176 | + <div class="w-48"> | |
| 177 | + <.input field={@filter_form[:actor]} label="Who" placeholder="handle" /> | |
| 178 | + </div> | |
| 179 | + <div :if={@repo_options} class="w-48"> | |
| 180 | + <.input | |
| 181 | + field={@filter_form[:repo]} | |
| 182 | + type="select" | |
| 183 | + label="Repository" | |
| 184 | + prompt="Any" | |
| 185 | + options={@repo_options} | |
| 186 | + /> | |
| 187 | + </div> | |
| 188 | + <button type="submit" class="btn btn-sm mb-2">Filter</button> | |
| 189 | + <.link | |
| 190 | + :if={@filters.actor || @filters.repo} | |
| 191 | + id="audit-filter-clear" | |
| 192 | + patch={Filters.to_path(@base, %{@filters | actor: nil, repo: nil, cursor: nil})} | |
| 193 | + class="btn btn-sm btn-ghost mb-2" | |
| 194 | + > | |
| 195 | + Clear | |
| 196 | + </.link> | |
| 197 | + </.form> | |
| 198 | + | |
| 111 | 199 | <ul id="audit-entries" phx-update="stream" class="divide-y divide-base-300"> |
| 112 | 200 | <li id="audit-empty" class="hidden only:block py-8 text-center text-sm opacity-60"> |
| 113 | − Nothing recorded here yet. | |
| 201 | + {if @filters.actor || @filters.repo, | |
| 202 | + do: "Nothing matches that filter.", | |
| 203 | + else: "Nothing recorded here yet."} | |
| 114 | 204 | </li> |
| 115 | 205 | <.entry_row :for={{id, entry} <- @entries} id={id} entry={entry} /> |
| 116 | 206 | </ul> |
@@ -126,4 +216,9 @@ defmodule GitGudWeb.AuditLive.Page do
| 126 | 216 | </nav> |
| 127 | 217 | """ |
| 128 | 218 | end |
| 219 | + | |
| 220 | + # A category tab keeps the actor and repository, and starts over at | |
| 221 | + # the newest entry. | |
| 222 | + defp tab(base, filters, category), | |
| 223 | + do: Filters.to_path(base, %{filters | category: category, cursor: nil}) | |
| 129 | 224 | end |
modified
lib/git_gud_web/live/audit_live/repo.ex
+6
−0
@@ -59,6 +59,10 @@ defmodule GitGudWeb.AuditLive.Repo do
| 59 | 59 | else: {:noreply, RepoAdmin.deny(socket, repo)} |
| 60 | 60 | end |
| 61 | 61 | |
| 62 | + @impl true | |
| 63 | + def handle_event("filter", %{"filter" => params}, socket), do: Page.filter(socket, params) | |
| 64 | + def handle_event(_event, _params, socket), do: {:noreply, socket} | |
| 65 | + | |
| 62 | 66 | defp allowed?(_repo, nil), do: false |
| 63 | 67 | defp allowed?(_repo, %{is_admin: true}), do: true |
| 64 | 68 | defp allowed?(%{organization_id: nil, owner_id: uid}, %{id: uid}), do: true |
@@ -83,7 +87,9 @@ defmodule GitGudWeb.AuditLive.Repo do
| 83 | 87 | <Page.body |
| 84 | 88 | base={@base} |
| 85 | 89 | filters={@filters} |
| 90 | + filter_form={@filter_form} | |
| 86 | 91 | categories={@categories} |
| 92 | + repo_options={@repo_options} | |
| 87 | 93 | entries={@streams.entries} |
| 88 | 94 | older={@older} |
| 89 | 95 | newer={@newer} |
added
test/git_gud_web/live/audit_live/filters_test.exs
+49
−0
@@ -0,0 +1,49 @@
| 1 | +defmodule GitGudWeb.AuditLive.FiltersTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias GitGudWeb.AuditLive.Filters | |
| 5 | + | |
| 6 | + @base "/orgs/acme/settings/audit" | |
| 7 | + @categories ~w(secrets members) | |
| 8 | + | |
| 9 | + defp parse(category, refine, refines \\ [:actor, :repo]), | |
| 10 | + do: Filters.parse(%{"category" => category, "refine" => refine}, @categories, refines) | |
| 11 | + | |
| 12 | + defp path(category, refine) do | |
| 13 | + {:ok, filters} = parse(category, refine) | |
| 14 | + Filters.to_path(@base, filters) | |
| 15 | + end | |
| 16 | + | |
| 17 | + test "every refinement, in the canonical order" do | |
| 18 | + assert path("secrets", ~w(actor alice repo web before 12)) == | |
| 19 | + @base <> "/secrets/actor/alice/repo/web/before/12" | |
| 20 | + end | |
| 21 | + | |
| 22 | + test "any order reads the same, and writes the canonical one" do | |
| 23 | + assert path("secrets", ~w(before 12 repo web actor alice)) == | |
| 24 | + @base <> "/secrets/actor/alice/repo/web/before/12" | |
| 25 | + end | |
| 26 | + | |
| 27 | + test "a refinement without a category spells it all" do | |
| 28 | + assert path(nil, ~w(actor alice)) == @base <> "/all/actor/alice" | |
| 29 | + assert path("all", ~w(actor alice)) == @base <> "/all/actor/alice" | |
| 30 | + end | |
| 31 | + | |
| 32 | + test "repeats, odd values, unknown keys and refinements the view lacks are errors" do | |
| 33 | + assert parse("secrets", ~w(actor alice actor bob)) == :error | |
| 34 | + assert parse("secrets", ~w(before 1 after 2)) == :error | |
| 35 | + assert parse("secrets", ["actor", "../etc"]) == :error | |
| 36 | + assert parse("secrets", ~w(actor)) == :error | |
| 37 | + assert parse("secrets", ~w(label x)) == :error | |
| 38 | + assert parse("secrets", ~w(repo web), [:actor]) == :error | |
| 39 | + end | |
| 40 | + | |
| 41 | + test "the form: blank is any, a leading @ is dropped, and the cursor goes" do | |
| 42 | + {:ok, filters} = parse("secrets", ~w(before 12)) | |
| 43 | + | |
| 44 | + assert {:ok, %Filters{actor: "alice", repo: nil, cursor: nil, category: "secrets"}} = | |
| 45 | + Filters.from_form(filters, %{"actor" => " @alice ", "repo" => ""}) | |
| 46 | + | |
| 47 | + assert Filters.from_form(filters, %{"actor" => "al ice"}) == :error | |
| 48 | + end | |
| 49 | +end |
modified
test/git_gud_web/live/audit_live/org_test.exs
+85
−0
@@ -153,6 +153,91 @@ defmodule GitGudWeb.AuditLive.OrgTest do
| 153 | 153 | end |
| 154 | 154 | end |
| 155 | 155 | |
| 156 | + describe "who and which repository" do | |
| 157 | + setup %{org: org, admin: admin} do | |
| 158 | + other = user_fixture() | |
| 159 | + {:ok, _} = Organizations.add_member(org, other, "admin") | |
| 160 | + {:ok, web} = GitGud.Repositories.create_repository_for_org(org, admin, %{"name" => "web"}) | |
| 161 | + {:ok, api} = GitGud.Repositories.create_repository_for_org(org, admin, %{"name" => "api"}) | |
| 162 | + | |
| 163 | + put = fn user, repo, name -> | |
| 164 | + Audit.record(Scope.for_user(user), "repo_secret.set", %{ | |
| 165 | + repository: repo, | |
| 166 | + target_label: name | |
| 167 | + }) | |
| 168 | + end | |
| 169 | + | |
| 170 | + {:ok, | |
| 171 | + other: other, | |
| 172 | + web: web, | |
| 173 | + api: api, | |
| 174 | + mine_web: put.(admin, web, "A"), | |
| 175 | + theirs_web: put.(other, web, "B"), | |
| 176 | + theirs_api: put.(other, api, "C")} | |
| 177 | + end | |
| 178 | + | |
| 179 | + test "an actor deep link", %{conn: conn, admin: admin, path: path, other: other} = ctx do | |
| 180 | + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/all/actor/#{other.handle}") | |
| 181 | + | |
| 182 | + refute has_element?(view, "#audit-entry-#{ctx.mine_web.id}") | |
| 183 | + assert has_element?(view, "#audit-entry-#{ctx.theirs_web.id}") | |
| 184 | + assert has_element?(view, "#audit-entry-#{ctx.theirs_api.id}") | |
| 185 | + end | |
| 186 | + | |
| 187 | + test "both, through the form, land on the canonical path", | |
| 188 | + %{ | |
| 189 | + conn: conn, | |
| 190 | + admin: admin, | |
| 191 | + path: path, | |
| 192 | + other: other | |
| 193 | + } = ctx do | |
| 194 | + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/secrets") | |
| 195 | + | |
| 196 | + view | |
| 197 | + |> form("#audit-filter", %{"filter" => %{"actor" => "@" <> other.handle, "repo" => "web"}}) | |
| 198 | + |> render_submit() | |
| 199 | + | |
| 200 | + assert_patch(view, path <> "/secrets/actor/#{other.handle}/repo/web") | |
| 201 | + assert has_element?(view, "#audit-entry-#{ctx.theirs_web.id}") | |
| 202 | + refute has_element?(view, "#audit-entry-#{ctx.theirs_api.id}") | |
| 203 | + refute has_element?(view, "#audit-entry-#{ctx.mine_web.id}") | |
| 204 | + | |
| 205 | + # A category tab keeps the filter; Clear drops it. | |
| 206 | + assert has_element?( | |
| 207 | + view, | |
| 208 | + ~s(#audit-category-all[href="#{path}/all/actor/#{other.handle}/repo/web"]) | |
| 209 | + ) | |
| 210 | + | |
| 211 | + view |> element("#audit-filter-clear") |> render_click() | |
| 212 | + assert_patch(view, path <> "/secrets") | |
| 213 | + end | |
| 214 | + | |
| 215 | + test "out of order comes back in order", %{conn: conn, admin: admin, path: path, other: other} do | |
| 216 | + canonical = path <> "/all/actor/#{other.handle}/repo/web" | |
| 217 | + | |
| 218 | + assert {:error, {:live_redirect, %{to: ^canonical}}} = | |
| 219 | + live(log_in_user(conn, admin), path <> "/all/repo/web/actor/#{other.handle}") | |
| 220 | + end | |
| 221 | + | |
| 222 | + test "a deleted repository, by its old name", %{conn: conn, admin: admin, path: path} = ctx do | |
| 223 | + {:ok, _} = GitGud.Repositories.delete_repository(ctx.api) | |
| 224 | + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/all/repo/api") | |
| 225 | + | |
| 226 | + assert has_element?(view, "#audit-entry-#{ctx.theirs_api.id}") | |
| 227 | + refute has_element?(view, "#audit-entry-#{ctx.theirs_web.id}") | |
| 228 | + assert has_element?(view, ~s(#audit-filter option[value="api"])) | |
| 229 | + end | |
| 230 | + | |
| 231 | + test "a deleted account, by its old handle", %{conn: conn, admin: admin, path: path} = ctx do | |
| 232 | + handle = ctx.other.handle | |
| 233 | + GitGud.Repo.delete!(ctx.other) | |
| 234 | + {:ok, view, _} = live(log_in_user(conn, admin), path <> "/all/actor/#{handle}") | |
| 235 | + | |
| 236 | + assert has_element?(view, "#audit-entry-#{ctx.theirs_web.id}") | |
| 237 | + refute has_element?(view, "#audit-entry-#{ctx.mine_web.id}") | |
| 238 | + end | |
| 239 | + end | |
| 240 | + | |
| 156 | 241 | test "the org page and settings header link here", %{ |
| 157 | 242 | conn: conn, |
| 158 | 243 | org: org, |
modified
test/git_gud_web/live/repo_audit_test.exs
+20
−0
@@ -239,6 +239,26 @@ defmodule GitGudWeb.RepoAuditTest do
| 239 | 239 | end |
| 240 | 240 | end |
| 241 | 241 | |
| 242 | + test "filters by who, not by repository", %{conn: conn} do | |
| 243 | + user = user_fixture() | |
| 244 | + repo = repo_for(user) | |
| 245 | + conn = log_in_user(conn, user) | |
| 246 | + | |
| 247 | + {:ok, lv, _} = live(conn, rpath(repo, "/settings")) | |
| 248 | + render_submit(lv, "save", %{"repository" => %{"description" => "x"}}) | |
| 249 | + [mine] = entries(repo) | |
| 250 | + | |
| 251 | + base = rpath(repo, "/settings/audit") | |
| 252 | + {:ok, view, _} = live(conn, base <> "/all/actor/#{user.handle}") | |
| 253 | + assert has_element?(view, "#audit-entry-#{mine.id}") | |
| 254 | + refute has_element?(view, "#audit-filter select") | |
| 255 | + | |
| 256 | + {:ok, view, _} = live(conn, base <> "/all/actor/someone-else") | |
| 257 | + refute has_element?(view, "#audit-entry-#{mine.id}") | |
| 258 | + | |
| 259 | + assert {:error, {:live_redirect, %{to: ^base}}} = live(conn, base <> "/all/repo/x") | |
| 260 | + end | |
| 261 | + | |
| 242 | 262 | test "it takes sudo mode", %{conn: conn} do |
| 243 | 263 | user = user_fixture() |
| 244 | 264 | repo = repo_for(user) |
Parents: 5dd3a85