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

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
"""
use Ash.Resource.Change
alias AshAuthentication.{AddOn.Confirmation.Actions, Jwt}
alias AshAuthentication.{AddOn.Confirmation.Actions, Info, Jwt}
alias Ash.{
Changeset,
@ -17,12 +17,13 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
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} ->
do_change(changeset, strategy)
:error ->
raise AssumptionFailed, message: "Strategy is missing from the changeset context."
raise AssumptionFailed,
message: "Action does not correlate with an authentication strategy"
end
end

View file

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

View file

@ -7,10 +7,15 @@ defmodule AshAuthentication.Info do
extension: AshAuthentication,
sections: [:authentication]
alias AshAuthentication.Strategy
alias Spark.Dsl.Extension
@type dsl_or_resource :: module | map
@doc """
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
def strategy(dsl_or_resource, name) do
dsl_or_resource
@ -24,7 +29,7 @@ defmodule AshAuthentication.Info do
@doc """
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
def 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)}`"
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

View file

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

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
"""
use Ash.Resource.Change
alias AshAuthentication.UserIdentity
alias AshAuthentication.{Info, UserIdentity}
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
import AshAuthentication.Utils, only: [is_falsy: 1]
@ -12,13 +12,15 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
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} ->
do_change(changeset, strategy)
: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

View file

@ -11,7 +11,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
"""
use Ash.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
import AshAuthentication.Utils, only: [is_falsy: 1]
@ -19,7 +19,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
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,
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_sign_in_action(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
dsl_state =
dsl_state
|> Transformer.replace_entity(
[:authentication, :strategies],
%{strategy | resource: resource},
~w[authentication strategies]a,
strategy,
&(&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}
else

View file

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

View file

@ -8,6 +8,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
use Ash.Resource.Change
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
alias AshAuthentication.Info
@doc false
@impl true
@ -15,7 +16,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
def change(changeset, _opts, _) do
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) <-
Changeset.get_argument(changeset, strategy.password_field),
{:ok, hash} <- strategy.hash_provider.hash(value) do

View file

@ -7,6 +7,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
use Ash.Resource.Validation
alias Ash.{Changeset, Error.Changes.InvalidArgument, Error.Framework.AssumptionFailed}
alias AshAuthentication.Info
@doc """
Validates that the password and password confirmation fields contain
@ -15,7 +16,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
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} ->
validate_password_confirmation(changeset, strategy)
@ -24,7 +25,9 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
: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

View file

@ -10,14 +10,14 @@ defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
"""
use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation}
alias AshAuthentication.Strategy.Password
alias AshAuthentication.{Info, Strategy.Password}
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
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
identity_field = strategy.identity_field

View file

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

View file

@ -13,7 +13,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
an authentication failed error.
"""
use Ash.Resource.Preparation
alias AshAuthentication.{Errors.AuthenticationFailed, Jwt}
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
alias Ash.{Query, Resource.Preparation}
require Ash.Query
@ -21,7 +21,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
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 = 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, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
dsl_state =
dsl_state
|> Transformer.replace_entity(
[:authentication, :strategies],
%{strategy | resource: resource},
~w[authentication strategies]a,
strategy,
&(&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}
end

View file

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