Packages

phoenix_kit

2.49.0
2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage audit.ex
Raw

lib/modules/storage/audit.ex

defmodule PhoenixKit.Modules.Storage.Audit do
@moduledoc """
The "who changed what" half of Media's history
(`dev_docs/plans/2026-10-03-job-runs.md`, §7): every change to the site's storage
*configuration* is written to the Activity log, and the action names live here, in
one place. Settings → Media → **History** reads them back, together with the
entries of the storage job runs.
| action | resource |
|---|---|
| `storage.profile.created` / `updated` / `deleted` | `storage_profile` |
| `storage.profile.bucket_added` / `bucket_changed` / `bucket_removed` | `storage_profile` |
| `storage.library.created` / `renamed` / `deleted` | `storage_library` |
| `storage.library.profile_changed` / `variant_set_changed` / `setting_changed` | `storage_library` |
| `storage.variant_set.created` / `updated` / `deleted` / `remade` | `storage_variant_set` |
| `storage.variant_set.size_created` / `size_updated` / `size_deleted` / `sizes_reset` | `storage_variant_set` |
| `storage.bucket.created` / `updated` / `deleted` | `storage_bucket` |
An entry carries the acting user (`actor_uuid:` in the context's options; the
LiveViews pass `PhoenixKitWeb.Actor.opts(socket)`), a mode (`manual` when a person
acted, `auto` when nothing did) and, for a change, the Activity log's own
`"changes"` shape — `%{"copies_originals" => %{"from" => 1, "to" => 2}}` — which the
feed already renders as "from → to". Entries are **permanent**: they are not pruned
by `activity_retention_days`, because "who changed this bucket last year" is exactly
what an audit is asked.
Only the **site's** configuration is recorded. A user's own library, profile and
bucket (V203–V206) are theirs and private; their changes are not written here. And
nothing secret is ever put in an entry: bucket changes name only the fields in
`bucket_fields/0`, never a key or a secret.
"""
import Ecto.Query
require Logger
alias PhoenixKit.Activity
alias PhoenixKit.Modules.Storage.Endpoint
@module_key "storage"
@bucket_fields ~w(name provider region endpoint bucket_name enabled priority integration_uuid cdn_url access_type max_size_mb)a
@entries_key {__MODULE__, :entries}
@callbacks_key {__MODULE__, :callbacks}
@external_key {__MODULE__, :external_transaction}
@doc "The Activity module key every storage entry (configuration and runs) is filed under."
@spec module_key() :: String.t()
def module_key, do: @module_key
@doc "The bucket fields whose changes are recorded: never a key or a secret."
@spec bucket_fields() :: [atom()]
def bucket_fields, do: @bucket_fields
@doc """
Runs a configuration mutation and its audit inserts in one transaction, then
announces the entries after commit. Nested audited mutations share the entries.
A failed mutation or audit insert rolls everything back.
Inside a caller's own repo transaction, entries commit with that transaction
but are not announced: this module cannot know when the caller commits. The
History tab's periodic refresh picks them up. Call this wrapper at the outer
boundary when immediate announcements are wanted.
"""
@spec transaction((-> result)) :: result | {:error, term()} when result: var
def transaction(fun) do
if Process.get(@entries_key), do: fun.(), else: transact(fun)
end
defp transact(fun) do
nested? = repo().in_transaction?()
result =
repo().transaction(fn ->
Process.put(@entries_key, [])
Process.put(@callbacks_key, [])
Process.put(@external_key, nested?)
try do
case fun.() do
{:error, reason} ->
repo().rollback(reason)
value ->
{value, Enum.reverse(Process.get(@entries_key)),
Enum.reverse(Process.get(@callbacks_key))}
end
after
Process.delete(@entries_key)
Process.delete(@callbacks_key)
Process.delete(@external_key)
end
end)
case result do
{:ok, {value, entries, callbacks}} ->
Enum.each(callbacks, &run_callback/1)
unless nested?, do: Enum.each(entries, &Activity.broadcast/1)
value
{:error, reason} ->
{:error, reason}
end
end
@doc """
Defers a cache invalidation or compatibility-settings sync until this module's
outer transaction commits. Callbacks are best effort. Inside a caller-owned
repo transaction it retains the callback's existing immediate behavior; callers
needing commit ordering must use `transaction/1` as their outer boundary.
"""
@spec after_commit((-> term())) :: :ok
def after_commit(fun) do
if Process.get(@callbacks_key) && not Process.get(@external_key) do
Process.put(@callbacks_key, [fun | Process.get(@callbacks_key)])
else
run_callback(fun)
end
:ok
end
defp run_callback(fun) do
fun.()
rescue
error ->
Logger.warning("Storage audit post-commit callback failed: #{Exception.message(error)}")
catch
:exit, reason ->
Logger.warning("Storage audit post-commit callback exited: #{inspect(reason)}")
end
@doc "Locks and reloads an audited resource so diffs describe its actual preceding state."
@spec change(struct(), (struct() -> result)) :: result | {:error, term()} when result: var
def change(%{__struct__: schema, uuid: uuid}, fun) do
transaction(fn ->
case repo().one(from(r in schema, where: r.uuid == ^uuid, lock: "FOR NO KEY UPDATE")) do
nil -> {:error, :not_found}
current -> fun.(current)
end
end)
end
@doc """
Writes one configuration entry. Insert failures roll back an enclosing audited
mutation; a standalone call returns the error. Entries are announced only after
the transaction owned by this module commits.
`opts` are the context call's: `:actor_uuid`, and `:mode` (default `"manual"` with an
actor, `"auto"` without). `audit: false` writes nothing (`:skipped`) — for a change
one context makes to another's rows as a consequence of the one that is recorded.
`metadata` is a map of string keys.
"""
@spec log(String.t(), String.t(), String.t() | nil, keyword(), map()) ::
{:ok, PhoenixKit.Activity.Entry.t()} | {:error, term()} | :skipped
def log(action, resource_type, resource_uuid, opts, metadata \\ %{}) do
if Keyword.get(opts, :audit, true),
do: transaction(fn -> write(action, resource_type, resource_uuid, opts, metadata) end),
else: :skipped
rescue
error -> failed(error)
catch
:exit, reason -> failed(reason)
end
defp write(action, resource_type, resource_uuid, opts, metadata) do
actor = Keyword.get(opts, :actor_uuid)
%{
module: @module_key,
action: action,
actor_uuid: actor,
mode: Keyword.get(opts, :mode, if(actor, do: "manual", else: "auto")),
resource_type: resource_type,
resource_uuid: resource_uuid && to_string(resource_uuid),
metadata: metadata,
permanent: true
}
|> Activity.entry_changeset()
|> repo().insert(mode: :savepoint)
|> case do
{:ok, entry} ->
Process.put(@entries_key, [entry | Process.get(@entries_key)])
{:ok, entry}
{:error, reason} ->
repo().rollback(reason)
end
end
defp failed(reason) do
if Process.get(@entries_key), do: repo().rollback(reason), else: {:error, reason}
end
defp repo, do: PhoenixKit.RepoHelper.repo()
@doc """
The `"changes"` map of an update: for each of `fields` that the changeset changes,
`%{"field" => %{"from" => old, "to" => new}}` (values made loggable). Empty when
nothing in `fields` changed.
"""
@spec changes(Ecto.Changeset.t(), [atom()]) :: %{String.t() => map()}
def changes(%Ecto.Changeset{} = changeset, fields) do
for field <- fields, Map.has_key?(changeset.changes, field), into: %{} do
{Atom.to_string(field),
%{
"from" => loggable(field, Map.get(changeset.data, field)),
"to" => loggable(field, Map.fetch!(changeset.changes, field))
}}
end
end
@doc """
The `"changes"` map between two values of the same struct (`before`, `after`) for
`fields`. Empty when none differs.
"""
@spec diff(map(), map(), [atom()]) :: %{String.t() => map()}
def diff(before, later, fields) do
for field <- fields, Map.get(before, field) != Map.get(later, field), into: %{} do
{Atom.to_string(field),
%{
"from" => loggable(field, Map.get(before, field)),
"to" => loggable(field, Map.get(later, field))
}}
end
end
@doc "Logs an update, when `changes` is not empty; `extra` is merged into the metadata."
@spec log_update(String.t(), String.t(), String.t() | nil, keyword(), map(), map()) :: :ok
def log_update(action, resource_type, uuid, opts, changes, extra \\ %{})
def log_update(_action, _type, _uuid, _opts, changes, _extra) when map_size(changes) == 0,
do: :ok
def log_update(action, resource_type, uuid, opts, changes, extra) do
log(action, resource_type, uuid, opts, Map.put(extra, Activity.changes_key(), changes))
:ok
end
defp loggable(:endpoint, value), do: Endpoint.audit_value(value)
defp loggable(:cdn_url, value), do: Endpoint.audit_value(value, local_path: false)
defp loggable(_field, value), do: loggable(value)
# Keep lists as JSON arrays rather than inspected Elixir source.
defp loggable(value) when is_list(value), do: Enum.map(value, &loggable/1)
defp loggable(nil), do: nil
defp loggable(value) when is_boolean(value) or is_number(value) or is_binary(value), do: value
defp loggable(value) when is_atom(value), do: Atom.to_string(value)
defp loggable(value), do: inspect(value)
end