neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Audit: the substrate — entries, catalog, recording in the change's transaction
8ccf537 · Gabriel Morell · 2026-10-20 21:31
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−0
| 257 | 257 | | `GITGUD_IMPORT_ALLOW_PRIVATE` (env) | `1`/`true`/`yes` lets imports, mirrors and upstream sync reach private, loopback and link-local addresses (`GitGud.OutboundUrl` `allow_private: [:import]`). The scheme rule and the no-local-paths rule still apply | unset (public addresses only) | |
| 258 | 258 | | `GITGUD_WEBHOOK_ALLOW_PRIVATE` (env) | The same for webhook delivery (`:webhook`). Webhooks pointing at a LAN host stop delivering until this is set | unset | |
| 259 | 259 | | `GITGUD_FEDERATION_ALLOW_PRIVATE` (env) | The same for ActivityPub actor fetches and inbox delivery (`:federation`) | unset | |
| 260 | +| `GitGud.Audit` `retention_days` | Audit log entries older than this many days are pruned daily. Config only, not an admin setting, so shortening the trail takes a deploy; boot refuses anything but `nil` or a positive integer | `nil` (kept forever) | | |
| 260 | 261 | | `GitGud.Workflows.Workers.StaleJobReaper` `stale_after_minutes` | A `running` job whose runner hasn't sent an `UpdateTask` or `UpdateLog` (`heartbeat_at`) for this long is re-queued | `5` | |
| 261 | 262 | | `GitGud.Packages.Token` `private_key_pem` | RS256 PEM that signs OCI registry Bearer tokens (env: `GITGUD_REGISTRY_PRIVATE_KEY`) | `nil` (registry auth disabled) | |
| 262 | 263 | | `GitGud.Packages.Token` `issuer` / `audience` / `key_id` / `ttl_seconds` | JWT claim defaults zot must agree on | `gitgud-registry` / `container_registry` / `gitgud-registry-1` / `300` | |
modified
config/config.exs
+6
−0
| 54 | 54 | {Oban.Plugins.Cron, |
| 55 | 55 | crontab: [ |
| 56 | 56 | {"@daily", GitGud.Federation.Workers.Retention}, |
| 57 | + # Audit log retention; a no-op while retention_days is nil. | |
| 58 | + {"@daily", GitGud.Audit.Workers.Prune}, | |
| 57 | 59 | # Walk a batch of stale remote actors every hour so cached public |
| 58 | 60 | # keys don't drift even when nobody's touching the actor. |
| 59 | 61 | {"@hourly", GitGud.Federation.Workers.RefreshRemoteActors}, |
| 78 | 80 | # tolerant of slow runners. Tune per deployment. |
| 79 | 81 | config :git_gud, GitGud.Workflows.Workers.StaleJobReaper, stale_after_minutes: 5 |
| 80 | 82 | |
| 83 | +# Audit log retention (GitGud.Audit). nil = keep every entry forever; | |
| 84 | +# an integer N prunes entries older than N days, daily. | |
| 85 | +config :git_gud, GitGud.Audit, retention_days: nil | |
| 86 | + | |
| 81 | 87 | # Federation retention. nil = keep forever. Tune per deployment. |
| 82 | 88 | config :git_gud, GitGud.Federation.Workers.Retention, |
| 83 | 89 | inbox_deliveries_days: nil, |
modified
lib/git_gud/accounts/scope.ex
+16
−1
| 18 | 18 | |
| 19 | 19 | alias GitGud.Accounts.User |
| 20 | 20 | |
| 21 | − defstruct user: nil | |
| 21 | + # `ip` and `user_agent` describe the request the scope came from, for | |
| 22 | + # the audit log (`GitGud.Audit`); nothing decides anything on them. | |
| 23 | + defstruct user: nil, ip: nil, user_agent: nil | |
| 22 | 24 | |
| 23 | 25 | @doc """ |
| 24 | 26 | Creates a scope for the given user. |
| 30 | 32 | end |
| 31 | 33 | |
| 32 | 34 | def for_user(nil), do: nil |
| 35 | + | |
| 36 | + @doc """ | |
| 37 | + Note the request's client address (a tuple or a string) and user | |
| 38 | + agent on the scope. A nil scope stays nil. | |
| 39 | + """ | |
| 40 | + def put_request(nil, _ip, _user_agent), do: nil | |
| 41 | + | |
| 42 | + def put_request(%__MODULE__{} = scope, ip, user_agent) do | |
| 43 | + %{scope | ip: GitGud.Audit.Actor.ip_string(ip), user_agent: truncate(user_agent)} | |
| 44 | + end | |
| 45 | + | |
| 46 | + defp truncate(ua) when is_binary(ua), do: String.slice(ua, 0, 512) | |
| 47 | + defp truncate(_), do: nil | |
| 33 | 48 | end |
modified
lib/git_gud/application.ex
+2
−0
| 18 | 18 | # lets them call back into /_hooks and /_ssh. |
| 19 | 19 | :ok = GitGudWeb.Plugs.HookAuth.put_secret() |
| 20 | 20 | :ok = GitGudWeb.PackagesWebhookController.warn_if_unconfigured() |
| 21 | + # A bad audit retention value refuses to boot rather than prune. | |
| 22 | + _ = GitGud.Audit.retention_days!() | |
| 21 | 23 | |
| 22 | 24 | topologies = Application.get_env(:libcluster, :topologies, []) |
| 23 | 25 |
added
lib/git_gud/audit.ex
+391
−0
| 1 | +defmodule GitGud.Audit do | |
| 2 | + @moduledoc """ | |
| 3 | + Who changed what, in an organization, a repository or the instance. | |
| 4 | + | |
| 5 | + An audit entry is written in the same transaction as the change it | |
| 6 | + describes: a change that fails leaves no entry, and an entry that | |
| 7 | + can't be written undoes the change. That's the difference from | |
| 8 | + `GitGud.Events`, which is issue and PR history and best-effort by | |
| 9 | + design — a missing audit entry is a missing security record. | |
| 10 | + | |
| 11 | + Audit.transact(scope, "repo_secret.set", | |
| 12 | + fn -> Secrets.put_repo_secret(repo, name, value) end, | |
| 13 | + fn _secret -> %{repository: repo, target_label: name} end) | |
| 14 | + | |
| 15 | + The call site — the LiveView handler or controller that knows who | |
| 16 | + acted — wraps the context call; contexts don't take an actor. The | |
| 17 | + actor is a `GitGud.Accounts.Scope` (a signed-in request, with its | |
| 18 | + address and user agent) or a `GitGud.Audit.Actor`. | |
| 19 | + | |
| 20 | + What may be recorded is `GitGud.Audit.Catalog`'s business. Values of | |
| 21 | + secrets, tokens and the like never are: metadata keys outside an | |
| 22 | + action's list are dropped. | |
| 23 | + | |
| 24 | + Every committed entry also leaves one `Logger.notice` with | |
| 25 | + `audit: true` metadata, for operators who ship logs. Entries are never | |
| 26 | + federated. They are kept forever unless | |
| 27 | + `config :git_gud, GitGud.Audit, retention_days: N` says otherwise | |
| 28 | + (`GitGud.Audit.Workers.Prune`). | |
| 29 | + """ | |
| 30 | + | |
| 31 | + import Ecto.Query, warn: false | |
| 32 | + | |
| 33 | + require Logger | |
| 34 | + | |
| 35 | + alias GitGud.Accounts.Scope | |
| 36 | + alias GitGud.Accounts.User | |
| 37 | + alias GitGud.Audit.Actor | |
| 38 | + alias GitGud.Audit.Entry | |
| 39 | + alias GitGud.Organizations.Organization | |
| 40 | + alias GitGud.Repo | |
| 41 | + alias GitGud.Repositories.Repository | |
| 42 | + | |
| 43 | + @type actor :: Scope.t() | Actor.t() | |
| 44 | + | |
| 45 | + @typedoc """ | |
| 46 | + What an entry is about, as `describe` returns it or `insert!/3` takes it: | |
| 47 | + | |
| 48 | + * `:organization` / `:repository` — the structs; ids and labels are | |
| 49 | + taken from them, and a repository's organization fills in | |
| 50 | + `:organization` when it isn't given | |
| 51 | + * `:target_type`, `:target_id`, `:target_label` — the thing acted on | |
| 52 | + (`:target_type` defaults to the catalog's) | |
| 53 | + * `:subject` — the `%User{}` acted on (an invitee, a banned user) | |
| 54 | + * `:metadata` — a map; only the catalog's keys survive | |
| 55 | + """ | |
| 56 | + @type attrs :: map() | |
| 57 | + | |
| 58 | + @doc """ | |
| 59 | + Run `fun` and record `action` in one transaction. | |
| 60 | + | |
| 61 | + `fun` returns `:ok`, `{:ok, value}`, `:error` or `{:error, reason}`, | |
| 62 | + and `transact/5` returns exactly that. On success `describe.(value)` | |
| 63 | + gives the entry's attrs, or `:skip` when nothing actually changed. | |
| 64 | + Any error rolls the transaction back and records nothing; an entry | |
| 65 | + that can't be inserted raises and rolls the change back. | |
| 66 | + | |
| 67 | + Options: | |
| 68 | + | |
| 69 | + * `record: :before` — insert the entry first and run `fun` second | |
| 70 | + (`describe` gets `:before`). For changes with effects outside the | |
| 71 | + database — deleting a repository removes its directory inside the | |
| 72 | + transaction, and an insert failing after that would leave the files | |
| 73 | + gone and the row back. | |
| 74 | + """ | |
| 75 | + @spec transact(actor(), String.t(), (-> term()), (term() -> attrs() | :skip), keyword()) :: | |
| 76 | + term() | |
| 77 | + def transact(actor, action, fun, describe, opts \\ []) | |
| 78 | + when is_function(fun, 0) and is_function(describe, 1) do | |
| 79 | + base = base!(actor, action) | |
| 80 | + | |
| 81 | + result = | |
| 82 | + Repo.transact(fn -> | |
| 83 | + case Keyword.get(opts, :record, :after) do | |
| 84 | + :before -> | |
| 85 | + entry = insert_described!(base, describe.(:before)) | |
| 86 | + wrap(fun.(), entry) | |
| 87 | + | |
| 88 | + :after -> | |
| 89 | + case fun.() do | |
| 90 | + ok when ok == :ok or (is_tuple(ok) and elem(ok, 0) == :ok) -> | |
| 91 | + value = if ok == :ok, do: :ok, else: elem(ok, 1) | |
| 92 | + {:ok, {ok, insert_described!(base, describe.(value))}} | |
| 93 | + | |
| 94 | + other -> | |
| 95 | + {:error, {:returned, other}} | |
| 96 | + end | |
| 97 | + end | |
| 98 | + end) | |
| 99 | + | |
| 100 | + case result do | |
| 101 | + {:ok, {original, entry}} -> | |
| 102 | + if entry, do: notice(entry) | |
| 103 | + original | |
| 104 | + | |
| 105 | + {:error, {:returned, original}} -> | |
| 106 | + original | |
| 107 | + end | |
| 108 | + end | |
| 109 | + | |
| 110 | + defp wrap(result, entry) do | |
| 111 | + if result == :ok or (is_tuple(result) and elem(result, 0) == :ok), | |
| 112 | + do: {:ok, {result, entry}}, | |
| 113 | + else: {:error, {:returned, result}} | |
| 114 | + end | |
| 115 | + | |
| 116 | + @doc """ | |
| 117 | + Record `action` with no change around it: something that already | |
| 118 | + happened (a prune, a registration recorded by its own context). | |
| 119 | + """ | |
| 120 | + @spec record(actor(), String.t(), attrs()) :: Entry.t() | |
| 121 | + def record(actor, action, attrs) do | |
| 122 | + entry = insert!(actor, action, attrs) | |
| 123 | + notice(entry) | |
| 124 | + entry | |
| 125 | + end | |
| 126 | + | |
| 127 | + @doc """ | |
| 128 | + Insert an entry inside a transaction the caller already has open. | |
| 129 | + | |
| 130 | + Leaves no log line, since the transaction may still roll back; call | |
| 131 | + `notice/1` with the entry once it has committed. | |
| 132 | + """ | |
| 133 | + @spec insert!(actor(), String.t(), attrs()) :: Entry.t() | |
| 134 | + def insert!(actor, action, attrs) do | |
| 135 | + actor |> base!(action) |> insert_described!(attrs) | |
| 136 | + end | |
| 137 | + | |
| 138 | + @doc "Add an entry to an `Ecto.Multi`; `attrs` may be a function of the changes so far." | |
| 139 | + @spec multi(Ecto.Multi.t(), term(), actor(), String.t(), attrs() | (map() -> attrs())) :: | |
| 140 | + Ecto.Multi.t() | |
| 141 | + def multi(%Ecto.Multi{} = multi, name, actor, action, attrs) do | |
| 142 | + base = base!(actor, action) | |
| 143 | + | |
| 144 | + Ecto.Multi.insert(multi, name, fn changes -> | |
| 145 | + attrs = if is_function(attrs, 1), do: attrs.(changes), else: attrs | |
| 146 | + base |> Map.merge(entry_attrs(attrs)) |> Entry.changeset() | |
| 147 | + end) | |
| 148 | + end | |
| 149 | + | |
| 150 | + @doc "The log line for a committed entry." | |
| 151 | + @spec notice(Entry.t()) :: :ok | |
| 152 | + def notice(%Entry{} = entry) do | |
| 153 | + where = | |
| 154 | + [entry.target_label, entry.repository_label || entry.organization_label] | |
| 155 | + |> Enum.reject(&is_nil/1) | |
| 156 | + |> Enum.join(" in ") | |
| 157 | + | |
| 158 | + Logger.notice( | |
| 159 | + "audit: #{entry.action} by #{entry.actor_label}" <> | |
| 160 | + if(where != "", do: " — #{where}", else: ""), | |
| 161 | + audit: true, | |
| 162 | + audit_action: entry.action, | |
| 163 | + audit_id: entry.id | |
| 164 | + ) | |
| 165 | + end | |
| 166 | + | |
| 167 | + # ── reading ────────────────────────────────────────────────────────── | |
| 168 | + | |
| 169 | + @default_limit 50 | |
| 170 | + @max_limit 100 | |
| 171 | + | |
| 172 | + @doc """ | |
| 173 | + A page of entries, newest first. | |
| 174 | + | |
| 175 | + `scope` is `{:org, org}` (the org's own entries and its repositories'), | |
| 176 | + `{:repo, repo}`, `:instance` (everything) or `{:user, user}` (what was | |
| 177 | + done to them, and what they did outside any org or repository). | |
| 178 | + | |
| 179 | + Filters: `:category`, `:actor_id`, `:repository_id`, `:organization_id`. | |
| 180 | + `cursor` is nil, `{:before, id}` (older) or `{:after, id}` (newer). | |
| 181 | + Returns `{entries, %{older: id | nil, newer: id | nil}}`: the cursors | |
| 182 | + for the next page in each direction, nil when there's none. | |
| 183 | + """ | |
| 184 | + def list(scope, filters \\ %{}, cursor \\ nil, limit \\ @default_limit) do | |
| 185 | + limit = limit |> max(1) |> min(@max_limit) | |
| 186 | + | |
| 187 | + base = | |
| 188 | + Entry | |
| 189 | + |> scoped(scope) | |
| 190 | + |> filtered(filters) | |
| 191 | + | |
| 192 | + {rows, more?} = | |
| 193 | + case cursor do | |
| 194 | + {:after, id} -> | |
| 195 | + rows = | |
| 196 | + from(e in base, where: e.id > ^id, order_by: [asc: e.id], limit: ^(limit + 1)) | |
| 197 | + |> Repo.all() | |
| 198 | + | |
| 199 | + {Enum.reverse(Enum.take(rows, limit)), length(rows) > limit} | |
| 200 | + | |
| 201 | + before -> | |
| 202 | + query = from(e in base, order_by: [desc: e.id], limit: ^(limit + 1)) | |
| 203 | + | |
| 204 | + query = | |
| 205 | + case before do | |
| 206 | + {:before, id} -> from(e in query, where: e.id < ^id) | |
| 207 | + _ -> query | |
| 208 | + end | |
| 209 | + | |
| 210 | + rows = Repo.all(query) | |
| 211 | + {Enum.take(rows, limit), length(rows) > limit} | |
| 212 | + end | |
| 213 | + | |
| 214 | + rows = Repo.preload(rows, [:actor]) | |
| 215 | + | |
| 216 | + cursors = | |
| 217 | + case {cursor, rows} do | |
| 218 | + {_, []} -> | |
| 219 | + %{older: nil, newer: nil} | |
| 220 | + | |
| 221 | + {{:after, _}, rows} -> | |
| 222 | + %{older: List.last(rows).id, newer: if(more?, do: hd(rows).id)} | |
| 223 | + | |
| 224 | + {{:before, _}, rows} -> | |
| 225 | + %{older: if(more?, do: List.last(rows).id), newer: hd(rows).id} | |
| 226 | + | |
| 227 | + {nil, rows} -> | |
| 228 | + %{older: if(more?, do: List.last(rows).id), newer: nil} | |
| 229 | + end | |
| 230 | + | |
| 231 | + {rows, cursors} | |
| 232 | + end | |
| 233 | + | |
| 234 | + defp scoped(query, {:org, %Organization{id: id}}), | |
| 235 | + do: from(e in query, where: e.organization_id == ^id) | |
| 236 | + | |
| 237 | + defp scoped(query, {:repo, %Repository{id: id}}), | |
| 238 | + do: from(e in query, where: e.repository_id == ^id) | |
| 239 | + | |
| 240 | + defp scoped(query, {:user, %User{id: id}}), | |
| 241 | + do: | |
| 242 | + from(e in query, | |
| 243 | + where: | |
| 244 | + e.subject_user_id == ^id or | |
| 245 | + (e.actor_id == ^id and is_nil(e.organization_id) and is_nil(e.repository_id)) | |
| 246 | + ) | |
| 247 | + | |
| 248 | + defp scoped(query, :instance), do: query | |
| 249 | + | |
| 250 | + defp filtered(query, filters) do | |
| 251 | + Enum.reduce(filters, query, fn | |
| 252 | + {:category, c}, q when is_binary(c) -> | |
| 253 | + from(e in q, where: e.category == ^c) | |
| 254 | + | |
| 255 | + {:actor_id, id}, q when is_integer(id) -> | |
| 256 | + from(e in q, where: e.actor_id == ^id) | |
| 257 | + | |
| 258 | + {:repository_id, id}, q when is_integer(id) -> | |
| 259 | + from(e in q, where: e.repository_id == ^id) | |
| 260 | + | |
| 261 | + {:organization_id, id}, q when is_integer(id) -> | |
| 262 | + from(e in q, where: e.organization_id == ^id) | |
| 263 | + | |
| 264 | + _, q -> | |
| 265 | + q | |
| 266 | + end) | |
| 267 | + end | |
| 268 | + | |
| 269 | + # ── retention ──────────────────────────────────────────────────────── | |
| 270 | + | |
| 271 | + @doc """ | |
| 272 | + Delete entries written before `cutoff`, `batch` at a time so no one | |
| 273 | + statement holds its locks for long. Returns how many went. | |
| 274 | + """ | |
| 275 | + @spec prune(DateTime.t(), pos_integer()) :: non_neg_integer() | |
| 276 | + def prune(%DateTime{} = cutoff, batch \\ 5_000) do | |
| 277 | + prune_batches(cutoff, batch, 0) | |
| 278 | + end | |
| 279 | + | |
| 280 | + defp prune_batches(cutoff, batch, total) do | |
| 281 | + ids = from(e in Entry, where: e.inserted_at < ^cutoff, select: e.id, limit: ^batch) | |
| 282 | + {count, _} = Repo.delete_all(from(e in Entry, where: e.id in subquery(ids))) | |
| 283 | + | |
| 284 | + if count < batch, do: total + count, else: prune_batches(cutoff, batch, total + count) | |
| 285 | + end | |
| 286 | + | |
| 287 | + @doc """ | |
| 288 | + The configured retention in days, nil to keep everything. Raises on | |
| 289 | + anything else, so a typo can't quietly delete the trail; checked at | |
| 290 | + boot by `GitGud.Application`. | |
| 291 | + """ | |
| 292 | + @spec retention_days!() :: pos_integer() | nil | |
| 293 | + def retention_days! do | |
| 294 | + case Keyword.get(Application.get_env(:git_gud, __MODULE__, []), :retention_days) do | |
| 295 | + nil -> | |
| 296 | + nil | |
| 297 | + | |
| 298 | + days when is_integer(days) and days > 0 -> | |
| 299 | + days | |
| 300 | + | |
| 301 | + other -> | |
| 302 | + raise ArgumentError, | |
| 303 | + "config :git_gud, GitGud.Audit, retention_days: must be nil or a positive integer, " <> | |
| 304 | + "got: #{inspect(other)}" | |
| 305 | + end | |
| 306 | + end | |
| 307 | + | |
| 308 | + # ── building ───────────────────────────────────────────────────────── | |
| 309 | + | |
| 310 | + # Who and what, checked before the change runs: an unknown action or | |
| 311 | + # an actor that can't be described raises with nothing changed. | |
| 312 | + defp base!(actor, action) do | |
| 313 | + if GitGud.Audit.Catalog.fetch(action) == nil, | |
| 314 | + do: | |
| 315 | + raise( | |
| 316 | + ArgumentError, | |
| 317 | + "unknown audit action #{inspect(action)}; add it to GitGud.Audit.Catalog" | |
| 318 | + ) | |
| 319 | + | |
| 320 | + Map.put(actor_attrs(actor), :action, action) | |
| 321 | + end | |
| 322 | + | |
| 323 | + defp actor_attrs(%Scope{user: %User{} = user} = scope), | |
| 324 | + do: %{ | |
| 325 | + actor_type: "user", | |
| 326 | + actor_id: user.id, | |
| 327 | + actor_label: user.handle, | |
| 328 | + via: "web", | |
| 329 | + ip: scope.ip, | |
| 330 | + user_agent: scope.user_agent | |
| 331 | + } | |
| 332 | + | |
| 333 | + defp actor_attrs(%Actor{} = actor), | |
| 334 | + do: %{ | |
| 335 | + actor_type: actor.type, | |
| 336 | + actor_id: actor.id, | |
| 337 | + actor_ref: actor.ref, | |
| 338 | + actor_label: actor.label, | |
| 339 | + via: actor.via, | |
| 340 | + ip: actor.ip, | |
| 341 | + user_agent: actor.user_agent | |
| 342 | + } | |
| 343 | + | |
| 344 | + defp actor_attrs(other), | |
| 345 | + do: | |
| 346 | + raise( | |
| 347 | + ArgumentError, | |
| 348 | + "an audit actor is a signed-in Scope or an Audit.Actor, got: #{inspect(other)}" | |
| 349 | + ) | |
| 350 | + | |
| 351 | + defp insert_described!(_base, :skip), do: nil | |
| 352 | + | |
| 353 | + defp insert_described!(base, attrs) when is_map(attrs) do | |
| 354 | + base | |
| 355 | + |> Map.merge(entry_attrs(attrs)) | |
| 356 | + |> Entry.changeset() | |
| 357 | + |> Repo.insert!() | |
| 358 | + end | |
| 359 | + | |
| 360 | + defp entry_attrs(attrs) do | |
| 361 | + {repo, attrs} = Map.pop(attrs, :repository) | |
| 362 | + {org, attrs} = Map.pop(attrs, :organization) | |
| 363 | + {subject, attrs} = Map.pop(attrs, :subject) | |
| 364 | + | |
| 365 | + attrs | |
| 366 | + |> Map.merge(repository_attrs(repo)) | |
| 367 | + |> Map.merge(organization_attrs(org || repository_org(repo))) | |
| 368 | + |> Map.merge(if subject, do: %{subject_user_id: subject.id}, else: %{}) | |
| 369 | + end | |
| 370 | + | |
| 371 | + defp repository_attrs(nil), do: %{} | |
| 372 | + | |
| 373 | + defp repository_attrs(%Repository{} = repo) do | |
| 374 | + repo = Repo.preload(repo, [:owner, :organization]) | |
| 375 | + | |
| 376 | + %{ | |
| 377 | + repository_id: repo.id, | |
| 378 | + repository_label: "#{GitGud.Repositories.Storage.repo_handle(repo)}/#{repo.name}" | |
| 379 | + } | |
| 380 | + end | |
| 381 | + | |
| 382 | + defp repository_org(%Repository{organization_id: id} = repo) when not is_nil(id), | |
| 383 | + do: Repo.preload(repo, :organization).organization | |
| 384 | + | |
| 385 | + defp repository_org(_), do: nil | |
| 386 | + | |
| 387 | + defp organization_attrs(nil), do: %{} | |
| 388 | + | |
| 389 | + defp organization_attrs(%Organization{} = org), | |
| 390 | + do: %{organization_id: org.id, organization_label: "@#{org.handle}"} | |
| 391 | +end |
added
lib/git_gud/audit/actor.ex
+66
−0
Click to load diff…
added
lib/git_gud/audit/catalog.ex
+44
−0
Click to load diff…
added
lib/git_gud/audit/entry.ex
+131
−0
Click to load diff…
added
lib/git_gud/audit/workers/prune.ex
+41
−0
Click to load diff…
modified
lib/git_gud_web/endpoint.ex
+4
−3
Click to load diff…
modified
lib/git_gud_web/plugs/client_ip.ex
+13
−4
Click to load diff…
modified
lib/git_gud_web/user_auth.ex
+12
−2
Click to load diff…
added
priv/repo/migrations/20261020211440_create_audit_entries.exs
+43
−0
Click to load diff…
added
test/git_gud/audit/workers/prune_test.exs
+73
−0
Click to load diff…
added
test/git_gud/audit_test.exs
+262
−0
Click to load diff…
added
test/git_gud_web/scope_request_test.exs
+45
−0
Click to load diff…
Parents: 88a5fab