Skip to content

Commit

Permalink
feat: Add store_action_inputs? option (#136)
Browse files Browse the repository at this point in the history
  • Loading branch information
Torkan authored Dec 2, 2024
1 parent 7d7afa6 commit 03ee06f
Show file tree
Hide file tree
Showing 11 changed files with 324 additions and 0 deletions.
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ spark_locals_without_parens = [
relationship_opts: 1,
resource_identifier: 1,
sensitive_attributes: 1,
store_action_inputs?: 1,
store_action_name?: 1,
store_resource_identifier?: 1,
table_name: 1,
Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-AshPaperTrail.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A section for configuring how versioning is derived for the resource.
| [`reference_source?`](#paper_trail-reference_source?){: #paper_trail-reference_source? } | `boolean` | `true` | Whether or not to create a foreign key reference from the version to the source. This should be set to `false` if you are allowing actual deletion of data. Only relevant for resources using the AshPostgres data layer. |
| [`relationship_opts`](#paper_trail-relationship_opts){: #paper_trail-relationship_opts } | `keyword` | | Options to pass to the has_many :paper_trail_versions relationship that is created on this resource. For example, `public?: true` to expose the relationship over graphql. See `d:Ash.Resource.Dsl.relationships.has_many`. |
| [`store_action_name?`](#paper_trail-store_action_name?){: #paper_trail-store_action_name? } | `boolean` | `false` | Whether or not to add the `version_action_name` attribute to the version resource. This is useful for auditing purposes. The `version_action_type` attribute is always stored. |
| [`store_action_inputs?`](#paper_trail-store_action_inputs?){: #paper_trail-store_action_inputs? } | `boolean` | `false` | Whether or not to add the `version_action_inputs` attribute to the version resource, which will store all attributes and arguments for the called action, redacting any sensitive values. This is useful for auditing purposes. The `version_action_inputs` attribute is always stored. |
| [`store_resource_identifier?`](#paper_trail-store_resource_identifier?){: #paper_trail-store_resource_identifier? } | `boolean` | `false` | Whether or not to add the `version_resource_identifier` attribute to the version resource. This is useful for auditing purposes. |
| [`resource_identifier`](#paper_trail-resource_identifier){: #paper_trail-resource_identifier } | `atom` | | A name to use for this resource in the `version_resource_identifier`. Defaults to `Ash.Resource.Info.short_name/1`. |
| [`version_extensions`](#paper_trail-version_extensions){: #paper_trail-version_extensions } | `keyword` | `[]` | Extensions that should be used by the version resource. For example: `extensions: [AshGraphql.Resource], notifier: [Ash.Notifiers.PubSub]` |
Expand Down
113 changes: 113 additions & 0 deletions lib/resource/changes/create_new_version.ex
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,75 @@ defmodule AshPaperTrail.Resource.Changes.CreateNewVersion do
|> build_changes(change_tracking_mode, changeset, result)
|> maybe_redact_changes(resource_attributes, sensitive_mode)

action_input_attrs =
changeset.action.accept
|> Enum.map(fn attr_name ->
attr_info = Ash.Resource.Info.attribute(changeset.resource, attr_name)
{present, params_value} = get_raw_params_value_if_present(changeset.params, attr_name)

%{
name: attr_name,
type: :attribute,
ash_type: attr_info.type,
present?: present,
params_value: params_value,
sensitive?: attr_info.sensitive?
}
end)

action_input_args =
changeset.action.arguments
|> Enum.map(fn arg ->
{present, params_value} = get_raw_params_value_if_present(changeset.params, arg.name)

%{
name: arg.name,
type: :argument,
ash_type: arg.type,
present?: present,
params_value: params_value,
sensitive?: arg.sensitive?
}
end)

action_inputs =
(action_input_attrs ++ action_input_args)
|> Enum.reduce(%{}, fn input, action_inputs ->
cond do
not input.present? ->
action_inputs

input.sensitive? ->
Map.put(action_inputs, input.name, "REDACTED")

true ->
input_value =
case input.type do
:attribute ->
changeset.casted_attributes[input.name] || changeset.attributes[input.name]

:argument ->
changeset.casted_arguments[input.name] || changeset.arguments[input.name]
end

constraints =
if Ash.Type.NewType.new_type?(input.ash_type) do
Ash.Type.NewType.constraints(input.ash_type, [])
else
Ash.Type.constraints(input.ash_type)
end

case Ash.Type.dump_to_embedded(input.ash_type, input_value, constraints) do
{:ok, value} ->
casted_params_value = extract_casted_params_values(value, input.params_value)
Map.put(action_inputs, input.name, casted_params_value)

:error ->
raise "Unable to serialize input value for #{input.name}"
end
end
end)

input =
Enum.reduce(belongs_to_actors, input, fn belongs_to_actor, input ->
with true <- is_struct(actor) && actor.__struct__ == belongs_to_actor.destination,
Expand All @@ -138,6 +207,7 @@ defmodule AshPaperTrail.Resource.Changes.CreateNewVersion do
version_source_id: Map.get(result, hd(Ash.Resource.Info.primary_key(changeset.resource))),
version_action_type: changeset.action.type,
version_action_name: changeset.action.name,
version_action_inputs: action_inputs,
version_resource_identifier:
AshPaperTrail.Resource.Info.resource_identifier(changeset.resource),
changes: changes
Expand All @@ -150,6 +220,49 @@ defmodule AshPaperTrail.Resource.Changes.CreateNewVersion do
end
end

defp get_raw_params_value_if_present(params, key) when is_atom(key) do
key_as_string = Atom.to_string(key)

present =
Map.has_key?(params, key) ||
Map.has_key?(params, key_as_string)

if present do
{true, Map.get(params, key) || Map.get(params, key_as_string)}
else
{false, nil}
end
end

defp extract_casted_params_values(casted_value, params_value) do
cond do
is_map(casted_value) and is_map(params_value) ->
params_keys = Map.keys(params_value)

Map.take(casted_value, params_keys)
|> Enum.map(fn {key, value} ->
{key, extract_casted_params_values(value, Map.get(params_value, key))}
end)
|> Enum.into(%{})

is_list(casted_value) and is_list(params_value) ->
Enum.zip(casted_value, params_value)
|> Enum.map(fn {casted_value, params_value} ->
extract_casted_params_values(casted_value, params_value)
end)

is_tuple(casted_value) and is_tuple(params_value) ->
Enum.zip(Tuple.to_list(casted_value), Tuple.to_list(params_value))
|> Enum.map(fn {casted_value, params_value} ->
extract_casted_params_values(casted_value, params_value)
end)
|> List.to_tuple()

true ->
casted_value
end
end

defp bulk_create_notifications!(changeset, version_changeset, inputs, actor) do
opts = [
context: %{ash_paper_trail?: true},
Expand Down
5 changes: 5 additions & 0 deletions lib/resource/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ defmodule AshPaperTrail.Resource.Info do
Spark.Dsl.Extension.get_opt(resource, [:paper_trail], :store_action_name?, false)
end

@spec store_action_inputs?(Spark.Dsl.t() | Ash.Resource.t()) :: boolean
def store_action_inputs?(resource) do
Spark.Dsl.Extension.get_opt(resource, [:paper_trail], :store_action_inputs?, false)
end

@spec store_resource_identifier?(Spark.Dsl.t() | Ash.Resource.t()) :: boolean
def store_resource_identifier?(resource) do
Spark.Dsl.Extension.get_opt(resource, [:paper_trail], :store_resource_identifier?, false)
Expand Down
6 changes: 6 additions & 0 deletions lib/resource/resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ defmodule AshPaperTrail.Resource do
doc:
"Whether or not to add the `version_action_name` attribute to the version resource. This is useful for auditing purposes. The `version_action_type` attribute is always stored."
],
store_action_inputs?: [
type: :boolean,
default: false,
doc:
"Whether or not to add the `version_action_inputs` attribute to the version resource, which will store all attributes and arguments for the called action, redacting any sensitive values. This is useful for auditing purposes. The `version_action_inputs` attribute is always stored."
],
store_resource_identifier?: [
type: :boolean,
default: false,
Expand Down
9 changes: 9 additions & 0 deletions lib/resource/transformers/create_version_resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule AshPaperTrail.Resource.Transformers.CreateVersionResource do
belongs_to_actors = AshPaperTrail.Resource.Info.belongs_to_actor(dsl_state)
reference_source? = AshPaperTrail.Resource.Info.reference_source?(dsl_state)
store_action_name? = AshPaperTrail.Resource.Info.store_action_name?(dsl_state)
store_action_inputs? = AshPaperTrail.Resource.Info.store_action_inputs?(dsl_state)
store_resource_identifier? = AshPaperTrail.Resource.Info.store_resource_identifier?(dsl_state)
version_extensions = AshPaperTrail.Resource.Info.version_extensions(dsl_state)

Expand Down Expand Up @@ -82,6 +83,7 @@ defmodule AshPaperTrail.Resource.Transformers.CreateVersionResource do
[
:version_action_type,
if(store_action_name?, do: :version_action_name, else: nil),
if(store_action_inputs?, do: :version_action_inputs, else: nil),
if(store_resource_identifier?, do: :version_resource_identifier, else: nil),
attributes |> Enum.map(& &1.name),
:version_source_id,
Expand Down Expand Up @@ -249,6 +251,13 @@ defmodule AshPaperTrail.Resource.Transformers.CreateVersionResource do
end
end

if unquote(store_action_inputs?) do
attribute :version_action_inputs, :map do
allow_nil?(false)
public? true
end
end

if unquote(store_resource_identifier?) do
attribute :version_resource_identifier, :atom do
allow_nil? false
Expand Down
73 changes: 73 additions & 0 deletions test/ash_paper_trail_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,79 @@ defmodule AshPaperTrailTest do
assert %{version_action_type: :update, version_action_name: :publish} = publish_version
end

test "the action inputs are stored correctly" do
assert AshPaperTrail.Resource.Info.store_action_inputs?(Posts.StoreInputsPost) == true

attrs =
Map.merge(@valid_attrs, %{
"secret" => "This will be redacted",
"req_arg" => "This is required",
"req_sensitive_arg" => "This is required and sensitive"
})

post = Posts.StoreInputsPost.create!(attrs, tenant: "acme")

[created_version] =
Ash.read!(Posts.StoreInputsPost.Version, tenant: "acme")

assert %{
version_action_inputs:
%{
author: %{first_name: "John", last_name: "Doe"},
body: "body",
tags: [%{tag: "ash"}, %{tag: "phoenix"}],
secret: "REDACTED",
subject: "subject",
req_arg: "This is required",
req_sensitive_arg: "REDACTED"
} = action_inputs
} = created_version

# Ensure that only passed attributes/arguments are stored
assert not Map.has_key?(action_inputs, :id)
assert not Map.has_key?(action_inputs, :published)
assert not Map.has_key?(action_inputs, :opt_arg)
assert not Map.has_key?(action_inputs, :opt_sensitive_arg)
assert action_inputs.author |> Map.keys() |> Enum.count() == 2

Enum.each(action_inputs.tags, fn tag ->
assert tag |> Map.keys() |> Enum.count() == 1
end)

Posts.StoreInputsPost.update!(
post,
%{
subject: "new subject",
req_arg: "This is still required",
req_sensitive_arg: "This will still be redacted",
opt_arg: "This is optional",
opt_sensitive_arg: "This will be redacted"
},
tenant: "acme"
)

[updated_version] =
Ash.read!(Posts.StoreInputsPost.Version, tenant: "acme")
|> Enum.filter(&(&1.version_action_name == :update))

assert %{
version_action_inputs: %{
subject: "new subject",
req_arg: "This is still required",
req_sensitive_arg: "REDACTED",
opt_arg: "This is optional",
opt_sensitive_arg: "REDACTED"
}
} = updated_version

assert not Map.has_key?(updated_version.version_action_inputs, :id)
assert not Map.has_key?(updated_version.version_action_inputs, :published)
assert not Map.has_key?(updated_version.version_action_inputs, :tags)
assert not Map.has_key?(updated_version.version_action_inputs, :author)
assert not Map.has_key?(updated_version.version_action_inputs, :secret)
assert not Map.has_key?(updated_version.version_action_inputs, :body)
end

test "a new version is created on destroy" do
assert %{subject: "subject", body: "body", id: post_id} =
post = Posts.Post.create!(@valid_attrs, tenant: "acme")
Expand Down
2 changes: 2 additions & 0 deletions test/support/posts/domain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ defmodule AshPaperTrail.Test.Posts.Domain do
resource AshPaperTrail.Test.Posts.Post.Version
resource AshPaperTrail.Test.Posts.FullDiffPost
resource AshPaperTrail.Test.Posts.FullDiffPost.Version
resource AshPaperTrail.Test.Posts.StoreInputsPost
resource AshPaperTrail.Test.Posts.StoreInputsPost.Version
end
end
1 change: 1 addition & 0 deletions test/support/posts/reaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule AshPaperTrail.Test.Posts.Reaction do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
storage: :type_and_value,
types: [
score: [
type: :integer
Expand Down
1 change: 1 addition & 0 deletions test/support/posts/source.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule AshPaperTrail.Test.Posts.Source do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
storage: :type_and_value,
types: [
blog: [
tag: :type,
Expand Down
Loading

0 comments on commit 03ee06f

Please sign in to comment.