improvement: remove the need for a strategy in changeset/query contexts. (#89)

The action -> strategy mapping is now stored directly in the resource DSL.

Closes #84.
This commit is contained in:
James Harton 2022-12-13 16:35:30 +13:00 committed by GitHub
parent 83d04170bb
commit 6dfbf03f11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 98 additions and 35 deletions

View file

@ -26,7 +26,6 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
{:ok, user} <- AshAuthentication.subject_to_user(subject, strategy.resource) do {:ok, user} <- AshAuthentication.subject_to_user(subject, strategy.resource) do
user user
|> Changeset.new() |> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_update(strategy.confirm_action_name, params) |> Changeset.for_update(strategy.confirm_action_name, params)
|> api.update(options) |> api.update(options)
else else
@ -53,7 +52,6 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
{:ok, _token_record} <- {:ok, _token_record} <-
token_resource token_resource
|> Changeset.new() |> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_create(store_changes_action, %{ |> Changeset.for_create(store_changes_action, %{
token: token, token: token,
extra_data: changes, extra_data: changes,

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
""" """
use Ash.Resource.Change use Ash.Resource.Change
alias AshAuthentication.{AddOn.Confirmation.Actions, Jwt} alias AshAuthentication.{AddOn.Confirmation.Actions, Info, Jwt}
alias Ash.{ alias Ash.{
Changeset, Changeset,
@ -17,12 +17,13 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
@impl true @impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
case Map.fetch(changeset.context, :strategy) do case Info.strategy_for_action(changeset.resource, changeset.action.name) do
{:ok, strategy} -> {:ok, strategy} ->
do_change(changeset, strategy) do_change(changeset, strategy)
:error -> :error ->
raise AssumptionFailed, message: "Strategy is missing from the changeset context." raise AssumptionFailed,
message: "Action does not correlate with an authentication strategy"
end end
end end

View file

@ -66,13 +66,16 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
:ok <- validate_confirmed_at_attribute(dsl_state, strategy), :ok <- validate_confirmed_at_attribute(dsl_state, strategy),
{:ok, dsl_state} <- maybe_build_change(dsl_state, Confirmation.ConfirmationHookChange), {:ok, dsl_state} <- maybe_build_change(dsl_state, Confirmation.ConfirmationHookChange),
{:ok, resource} <- persisted_option(dsl_state, :module) do {:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
dsl_state = dsl_state =
dsl_state dsl_state
|> Transformer.replace_entity( |> Transformer.replace_entity(
[:authentication, :add_ons], [:authentication, :add_ons],
%{strategy | resource: resource}, strategy,
&(&1.name == strategy.name) &(&1.name == strategy.name)
) )
|> Transformer.persist({:authentication_action, strategy.confirm_action_name}, strategy)
{:ok, dsl_state} {:ok, dsl_state}
else else

View file

@ -7,10 +7,15 @@ defmodule AshAuthentication.Info do
extension: AshAuthentication, extension: AshAuthentication,
sections: [:authentication] sections: [:authentication]
alias AshAuthentication.Strategy
alias Spark.Dsl.Extension
@type dsl_or_resource :: module | map
@doc """ @doc """
Retrieve a named strategy from a resource. Retrieve a named strategy from a resource.
""" """
@spec strategy(dsl_or_resource :: map | module, atom) :: {:ok, strategy} | :error @spec strategy(dsl_or_resource | module, atom) :: {:ok, strategy} | :error
when strategy: struct when strategy: struct
def strategy(dsl_or_resource, name) do def strategy(dsl_or_resource, name) do
dsl_or_resource dsl_or_resource
@ -24,7 +29,7 @@ defmodule AshAuthentication.Info do
@doc """ @doc """
Retrieve a named strategy from a resource (raising version). Retrieve a named strategy from a resource (raising version).
""" """
@spec strategy!(dsl_or_resource :: map | module, atom) :: strategy | no_return @spec strategy!(dsl_or_resource | module, atom) :: strategy | no_return
when strategy: struct when strategy: struct
def strategy!(dsl_or_resource, name) do def strategy!(dsl_or_resource, name) do
case strategy(dsl_or_resource, name) do case strategy(dsl_or_resource, name) do
@ -35,4 +40,31 @@ defmodule AshAuthentication.Info do
raise "No strategy named `#{inspect(name)}` found on resource `#{inspect(dsl_or_resource)}`" raise "No strategy named `#{inspect(name)}` found on resource `#{inspect(dsl_or_resource)}`"
end end
end end
@doc """
Given an action name, retrieve the strategy it is for from the DSL
configuration.
"""
@spec strategy_for_action(dsl_or_resource, atom) :: {:ok, Strategy.t()} | :error
def strategy_for_action(dsl_or_resource, action_name) do
case Extension.get_persisted(dsl_or_resource, {:authentication_action, action_name}) do
nil -> :error
value -> {:ok, value}
end
end
@doc """
Given an action name, retrieve the strategy it is for from the DSL
configuration.
"""
@spec strategy_for_action!(dsl_or_resource, atom) :: Strategy.t() | no_return
def strategy_for_action!(dsl_or_resource, action_name) do
case strategy_for_action(dsl_or_resource, action_name) do
{:ok, value} ->
value
:error ->
raise "No strategy action named `#{inspect(action_name)}` found on resource `#{inspect(dsl_or_resource)}`"
end
end
end end

View file

@ -26,7 +26,6 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
strategy.resource strategy.resource
|> Query.new() |> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(strategy.sign_in_action_name, params) |> Query.for_read(strategy.sign_in_action_name, params)
|> api.read(options) |> api.read(options)
|> case do |> case do
@ -45,7 +44,6 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
strategy.resource strategy.resource
|> Changeset.new() |> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_create(strategy.register_action_name, params, |> Changeset.for_create(strategy.register_action_name, params,
upsert?: true, upsert?: true,
upsert_identity: action.upsert_identity upsert_identity: action.upsert_identity

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
""" """
use Ash.Resource.Change use Ash.Resource.Change
alias AshAuthentication.UserIdentity alias AshAuthentication.{Info, UserIdentity}
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change} alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
import AshAuthentication.Utils, only: [is_falsy: 1] import AshAuthentication.Utils, only: [is_falsy: 1]
@ -12,13 +12,15 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
@impl true @impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
case Map.fetch(changeset.context, :strategy) do case Info.strategy_for_action(changeset.resource, changeset.action.name) do
{:ok, strategy} -> {:ok, strategy} ->
do_change(changeset, strategy) do_change(changeset, strategy)
:error -> :error ->
{:error, {:error,
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")} AssumptionFailed.exception(
message: "Action does not correlate with an authentication strategy"
)}
end end
end end

View file

@ -11,7 +11,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
""" """
use Ash.Resource.Preparation use Ash.Resource.Preparation
alias Ash.{Error.Framework.AssumptionFailed, Query, Resource.Preparation} alias Ash.{Error.Framework.AssumptionFailed, Query, Resource.Preparation}
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, UserIdentity} alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt, UserIdentity}
require Ash.Query require Ash.Query
import AshAuthentication.Utils, only: [is_falsy: 1] import AshAuthentication.Utils, only: [is_falsy: 1]
@ -19,7 +19,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
@impl true @impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do def prepare(query, _opts, _context) do
case Map.fetch(query.context, :strategy) do case Info.strategy_for_action(query.resource, query.action.name) do
:error -> :error ->
{:error, {:error,
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")} AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")}

View file

@ -52,13 +52,23 @@ defmodule AshAuthentication.Strategy.OAuth2.Transformer do
:ok <- maybe_validate_register_action(dsl_state, strategy), :ok <- maybe_validate_register_action(dsl_state, strategy),
:ok <- maybe_validate_sign_in_action(dsl_state, strategy), :ok <- maybe_validate_sign_in_action(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do {:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
dsl_state = dsl_state =
dsl_state dsl_state
|> Transformer.replace_entity( |> Transformer.replace_entity(
[:authentication, :strategies], ~w[authentication strategies]a,
%{strategy | resource: resource}, strategy,
&(&1.name == strategy.name) &(&1.name == strategy.name)
) )
|> then(fn dsl_state ->
~w[register_action_name sign_in_action_name]a
|> Stream.map(&Map.get(strategy, &1))
|> Enum.reduce(
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
end)
{:ok, dsl_state} {:ok, dsl_state}
else else

View file

@ -19,7 +19,6 @@ defmodule AshAuthentication.Strategy.Password.Actions do
strategy.resource strategy.resource
|> Query.new() |> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(strategy.sign_in_action_name, params) |> Query.for_read(strategy.sign_in_action_name, params)
|> api.read(options) |> api.read(options)
|> case do |> case do
@ -37,7 +36,6 @@ defmodule AshAuthentication.Strategy.Password.Actions do
strategy.resource strategy.resource
|> Changeset.new() |> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_create(strategy.register_action_name, params) |> Changeset.for_create(strategy.register_action_name, params)
|> api.create(options) |> api.create(options)
end end
@ -55,7 +53,6 @@ defmodule AshAuthentication.Strategy.Password.Actions do
strategy.resource strategy.resource
|> Query.new() |> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(resettable.request_password_reset_action_name, params) |> Query.for_read(resettable.request_password_reset_action_name, params)
|> api.read(options) |> api.read(options)
|> case do |> case do
@ -85,7 +82,6 @@ defmodule AshAuthentication.Strategy.Password.Actions do
user user
|> Changeset.new() |> Changeset.new()
|> Changeset.set_context(%{strategy: strategy})
|> Changeset.for_update(resettable.password_reset_action_name, params) |> Changeset.for_update(resettable.password_reset_action_name, params)
|> api.update(options) |> api.update(options)
else else

View file

@ -8,6 +8,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
use Ash.Resource.Change use Ash.Resource.Change
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change} alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
alias AshAuthentication.Info
@doc false @doc false
@impl true @impl true
@ -15,7 +16,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
def change(changeset, _opts, _) do def change(changeset, _opts, _) do
changeset changeset
|> Changeset.before_action(fn changeset -> |> Changeset.before_action(fn changeset ->
with {:ok, strategy} <- Map.fetch(changeset.context, :strategy), with {:ok, strategy} <- Info.strategy_for_action(changeset.resource, changeset.action.name),
value when is_binary(value) <- value when is_binary(value) <-
Changeset.get_argument(changeset, strategy.password_field), Changeset.get_argument(changeset, strategy.password_field),
{:ok, hash} <- strategy.hash_provider.hash(value) do {:ok, hash} <- strategy.hash_provider.hash(value) do

View file

@ -7,6 +7,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
use Ash.Resource.Validation use Ash.Resource.Validation
alias Ash.{Changeset, Error.Changes.InvalidArgument, Error.Framework.AssumptionFailed} alias Ash.{Changeset, Error.Changes.InvalidArgument, Error.Framework.AssumptionFailed}
alias AshAuthentication.Info
@doc """ @doc """
Validates that the password and password confirmation fields contain Validates that the password and password confirmation fields contain
@ -15,7 +16,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
@impl true @impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()} @spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
def validate(changeset, _) do def validate(changeset, _) do
case Map.fetch(changeset.context, :strategy) do case Info.strategy_for_action(changeset.resource, changeset.action.name) do
{:ok, %{confirmation_required?: true} = strategy} -> {:ok, %{confirmation_required?: true} = strategy} ->
validate_password_confirmation(changeset, strategy) validate_password_confirmation(changeset, strategy)
@ -24,7 +25,9 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
:error -> :error ->
{:error, {:error,
AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")} AssumptionFailed.exception(
message: "Action does not correlate with an authentication strategy"
)}
end end
end end

View file

@ -10,14 +10,14 @@ defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
""" """
use Ash.Resource.Preparation use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation} alias Ash.{Query, Resource.Preparation}
alias AshAuthentication.Strategy.Password alias AshAuthentication.{Info, Strategy.Password}
require Ash.Query require Ash.Query
@doc false @doc false
@impl true @impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do def prepare(query, _opts, _context) do
strategy = Map.fetch!(query.context, :strategy) strategy = Info.strategy_for_action!(query.resource, query.action.name)
if Enum.any?(strategy.resettable) do if Enum.any?(strategy.resettable) do
identity_field = strategy.identity_field identity_field = strategy.identity_field

View file

@ -5,13 +5,13 @@ defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do
use Ash.Resource.Validation use Ash.Resource.Validation
alias Ash.{Changeset, Error.Changes.InvalidArgument} alias Ash.{Changeset, Error.Changes.InvalidArgument}
alias AshAuthentication.Jwt alias AshAuthentication.{Info, Jwt}
@doc false @doc false
@impl true @impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()} @spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()}
def validate(changeset, _) do def validate(changeset, _) do
with {:ok, strategy} <- Map.fetch(changeset.context, :strategy), with {:ok, strategy} <- Info.strategy_for_action(changeset.resource, changeset.action.name),
token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token), token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token),
{:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource), {:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource),
{:ok, [resettable]} <- Map.fetch(strategy, :resettable), {:ok, [resettable]} <- Map.fetch(strategy, :resettable),

View file

@ -13,7 +13,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
an authentication failed error. an authentication failed error.
""" """
use Ash.Resource.Preparation use Ash.Resource.Preparation
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt} alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
alias Ash.{Query, Resource.Preparation} alias Ash.{Query, Resource.Preparation}
require Ash.Query require Ash.Query
@ -21,7 +21,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
@impl true @impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do def prepare(query, _opts, _context) do
strategy = Map.fetch!(query.context, :strategy) strategy = Info.strategy_for_action!(query.resource, query.action.name)
identity_field = strategy.identity_field identity_field = strategy.identity_field
identity = Query.get_argument(query, identity_field) identity = Query.get_argument(query, identity_field)

View file

@ -71,13 +71,35 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
:ok <- validate_sign_in_action(dsl_state, strategy), :ok <- validate_sign_in_action(dsl_state, strategy),
{:ok, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy), {:ok, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do {:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
dsl_state = dsl_state =
dsl_state dsl_state
|> Transformer.replace_entity( |> Transformer.replace_entity(
[:authentication, :strategies], ~w[authentication strategies]a,
%{strategy | resource: resource}, strategy,
&(&1.name == strategy.name) &(&1.name == strategy.name)
) )
|> then(fn dsl_state ->
~w[sign_in_action_name register_action_name]a
|> Stream.map(&Map.get(strategy, &1))
|> Enum.reduce(
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
end)
|> then(fn dsl_state ->
strategy
|> Map.get(:resettable, [])
|> Stream.flat_map(fn resettable ->
~w[request_password_reset_action_name password_reset_action_name]a
|> Stream.map(&Map.get(resettable, &1))
end)
|> Enum.reduce(
dsl_state,
&Transformer.persist(&2, {:authentication_action, &1}, strategy)
)
end)
{:ok, dsl_state} {:ok, dsl_state}
end end

View file

@ -79,12 +79,9 @@ defmodule DataCase do
|> Map.put_new(:password, password) |> Map.put_new(:password, password)
|> Map.put_new(:password_confirmation, password) |> Map.put_new(:password_confirmation, password)
{:ok, strategy} = AshAuthentication.Info.strategy(Example.User, :password)
user = user =
Example.User Example.User
|> Ash.Changeset.new() |> Ash.Changeset.new()
|> Ash.Changeset.set_context(%{strategy: strategy})
|> Ash.Changeset.for_create(:register_with_password, attrs) |> Ash.Changeset.for_create(:register_with_password, attrs)
|> Example.create!() |> Example.create!()