I can’t quite get my head around what I need to do to make this test pass. Although I took the Ash training last year, I never had the chance to put it to practice so all that learning melted away – either due to time passing or all those beers @zachdaniel treated us at Elixir Conf EU.
Here’s the test
test "can save cookie" do
user = generate(confirmed_user())
assert {:ok, updated_user} =
user
|> Ash.Changeset.for_update(:add_cookie, %{cookie: "some_cookie"}, actor: user)
|> Ash.update()
assert updated_user.cookie == "some_cookie"
end
I get the error:
...
bread_crumbs: ["Error returned from: Bubblelog.Accounts.User.add_cookie"],
class: :forbidden"
...
Full error
%Ash.Error.Forbidden{
bread_crumbs: ["Error returned from: Bubblelog.Accounts.User.add_cookie"],
changeset: "#Changeset<>",
errors: [
%Ash.Error.Forbidden.Policy{
scenarios: [],
facts: %{
false => false,
true => true,
{Ash.Policy.Check.Action, [action: [:add_cookie], access_type: :filter]} => true,
{Ash.Policy.Check.ActorPresent, [access_type: :filter]} => true,
{AshAuthentication.Checks.AshAuthenticationInteraction,
[access_type: :filter]} => false
},
filter: nil,
actor: %Bubblelog.Accounts.User{
id: "617873b3-5013-4a49-8789-b801d747d34f",
email: #Ash.CiString<"simple0@example.com">,
confirmed_at: ~U[2025-06-18 07:25:37.147928Z],
__meta__: #Ecto.Schema.Metadata<:loaded, "users">
},
policy_breakdown?: false,
must_pass_strict_check?: false,
for_fields: nil,
subject: #Ash.Changeset<
domain: Bubblelog.Accounts,
action_type: :update,
action: :add_cookie,
attributes: %{cookie: "some_cookie"},
relationships: %{},
errors: [],
data: %Bubblelog.Accounts.User{
id: "617873b3-5013-4a49-8789-b801d747d34f",
email: #Ash.CiString<"simple0@example.com">,
confirmed_at: ~U[2025-06-18 07:25:37.147928Z],
__meta__: #Ecto.Schema.Metadata<:loaded, "users">
},
valid?: true
>,
context_description: nil,
policies: [
%Ash.Policy.Policy{
condition: [
{AshAuthentication.Checks.AshAuthenticationInteraction,
[access_type: :filter]}
],
policies: [
%Ash.Policy.Check{
check: {Ash.Policy.Check.Static, [result: true]},
check_module: Ash.Policy.Check.Static,
check_opts: [result: true, access_type: :filter],
type: :authorize_if
}
],
bypass?: true,
description: nil,
access_type: :filter
},
%Ash.Policy.Policy{
condition: [
{Ash.Policy.Check.Action,
[action: [:add_cookie], access_type: :filter]}
],
policies: [
%Ash.Policy.Check{
check: {Ash.Policy.Check.ActorPresent, []},
check_module: Ash.Policy.Check.ActorPresent,
check_opts: [access_type: :filter],
type: :authorize_if
}
],
bypass?: nil,
description: nil,
access_type: :filter
},
%Ash.Policy.Policy{
condition: [
{Ash.Policy.Check.Static, [result: true, access_type: :filter]}
],
policies: [
%Ash.Policy.Check{
check: {Ash.Policy.Check.Static, [result: true]},
check_module: Ash.Policy.Check.Static,
check_opts: [result: true, access_type: :filter],
type: :forbid_if
}
],
bypass?: nil,
description: nil,
access_type: :filter
}
],
resource: Bubblelog.Accounts.User,
solver_statement: {:and,
{:or, {Ash.Policy.Check.Static, [result: true, access_type: :filter]},
{Ash.Policy.Check.Action, [action: [:add_cookie], access_type: :filter]}},
{:and,
{:or,
{:not,
{Ash.Policy.Check.Action,
[action: [:add_cookie], access_type: :filter]}},
{:and,
{Ash.Policy.Check.Action,
[action: [:add_cookie], access_type: :filter]},
{Ash.Policy.Check.ActorPresent, [access_type: :filter]}}},
{:or,
{:and, {Ash.Policy.Check.Static, [result: true, access_type: :filter]},
false},
{:not, {Ash.Policy.Check.Static, [result: true, access_type: :filter]}}}}},
domain: Bubblelog.Accounts,
action: %Ash.Resource.Actions.Update{
name: :add_cookie,
primary?: false,
description: "Add or update a cookie for the user",
error_handler: nil,
accept: [:cookie],
require_attributes: [],
allow_nil_input: [],
skip_unknown_inputs: [],
manual: nil,
manual?: false,
require_atomic?: false,
atomic_upgrade?: false,
atomic_upgrade_with: nil,
action_select: nil,
notifiers: [],
atomics: [],
delay_global_validations?: false,
skip_global_validations?: false,
arguments: [],
changes: [],
reject: [],
metadata: [],
transaction?: true,
touches_resources: [],
type: :update
},
changeset_doesnt_match_filter: false,
splode: Ash.Error,
bread_crumbs: ["Error returned from: Bubblelog.Accounts.User.add_cookie"],
vars: [],
path: [],
stacktrace: #Splode.Stacktrace<>,
class: :forbidden
}
]
}
In the user resource I’ve got the following
# user.ex
actions do
...
update :add_cookie do
accept [:cookie]
end
...
end
policies do
...
policy action(:add_cookie) do
authorize_if actor_present()
authorize_if relates_to_actor_via(:id)
end
policy always() do
forbid_if always()
end
end
attributes do
...
attribute :cookie, :string do
sensitive? true
end
end
if I comment out that policy always()
the test passes – so I’m guessing the solution lives there somewhere?
full user.ex
defmodule Bubblelog.Accounts.User do
use Ash.Resource,
otp_app: :bubblelog,
domain: Bubblelog.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication]
authentication do
add_ons do
log_out_everywhere do
apply_on_password_change? true
end
confirmation :confirm_new_user do
monitor_fields [:email]
confirm_on_create? true
confirm_on_update? false
require_interaction? true
confirmed_at_field :confirmed_at
auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token]
sender Bubblelog.Accounts.User.Senders.SendNewUserConfirmationEmail
end
end
tokens do
enabled? true
token_resource Bubblelog.Accounts.Token
signing_secret Bubblelog.Secrets
store_all_tokens? true
require_token_presence_for_authentication? true
end
strategies do
password :password do
identity_field :email
hash_provider AshAuthentication.BcryptProvider
resettable do
sender Bubblelog.Accounts.User.Senders.SendPasswordResetEmail
# these configurations will be the default in a future release
password_reset_action_name :reset_password_with_token
request_password_reset_action_name :request_password_reset_token
end
end
magic_link do
identity_field :email
registration_enabled? true
require_interaction? true
sender Bubblelog.Accounts.User.Senders.SendMagicLinkEmail
end
end
end
postgres do
table "users"
repo Bubblelog.Repo
end
actions do
defaults [:read]
update :add_cookie do
accept [:cookie]
description "Add or update the Bubble cookie for the user"
end
read :get_by_subject do
description "Get a user by the subject claim in a JWT"
argument :subject, :string, allow_nil?: false
get? true
prepare AshAuthentication.Preparations.FilterBySubject
end
update :change_password do
# Use this action to allow users to change their password by providing
# their current password and a new password.
require_atomic? false
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false
argument :password, :string,
sensitive?: true,
allow_nil?: false,
constraints: [min_length: 8]
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
validate confirm(:password, :password_confirmation)
validate {AshAuthentication.Strategy.Password.PasswordValidation,
strategy_name: :password, password_argument: :current_password}
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
end
read :sign_in_with_password do
description "Attempt to sign in using a email and password."
get? true
argument :email, :ci_string do
description "The email to use for retrieving the user."
allow_nil? false
end
argument :password, :string do
description "The password to check for the matching user."
allow_nil? false
sensitive? true
end
# validates the provided email and password and generates a token
prepare AshAuthentication.Strategy.Password.SignInPreparation
metadata :token, :string do
description "A JWT that can be used to authenticate the user."
allow_nil? false
end
end
read :sign_in_with_token do
# In the generated sign in components, we validate the
# email and password directly in the LiveView
# and generate a short-lived token that can be used to sign in over
# a standard controller action, exchanging it for a standard token.
# This action performs that exchange. If you do not use the generated
# liveviews, you may remove this action, and set
# `sign_in_tokens_enabled? false` in the password strategy.
description "Attempt to sign in using a short-lived sign in token."
get? true
argument :token, :string do
description "The short-lived sign in token."
allow_nil? false
sensitive? true
end
# validates the provided sign in token and generates a token
prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation
metadata :token, :string do
description "A JWT that can be used to authenticate the user."
allow_nil? false
end
end
create :register_with_password do
description "Register a new user with a email and password."
argument :email, :ci_string do
allow_nil? false
end
argument :password, :string do
description "The proposed password for the user, in plain text."
allow_nil? false
constraints min_length: 8
sensitive? true
end
argument :password_confirmation, :string do
description "The proposed password for the user (again), in plain text."
allow_nil? false
sensitive? true
end
# Sets the email from the argument
change set_attribute(:email, arg(:email))
# Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange
# Generates an authentication token for the user
change AshAuthentication.GenerateTokenChange
# validates that the password matches the confirmation
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
metadata :token, :string do
description "A JWT that can be used to authenticate the user."
allow_nil? false
end
end
action :request_password_reset_token do
description "Send password reset instructions to a user if they exist."
argument :email, :ci_string do
allow_nil? false
end
# creates a reset token and invokes the relevant senders
run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_email}
end
read :get_by_email do
description "Looks up a user by their email"
get? true
argument :email, :ci_string do
allow_nil? false
end
filter expr(email == ^arg(:email))
end
update :reset_password_with_token do
argument :reset_token, :string do
allow_nil? false
sensitive? true
end
argument :password, :string do
description "The proposed password for the user, in plain text."
allow_nil? false
constraints min_length: 8
sensitive? true
end
argument :password_confirmation, :string do
description "The proposed password for the user (again), in plain text."
allow_nil? false
sensitive? true
end
# validates the provided reset token
validate AshAuthentication.Strategy.Password.ResetTokenValidation
# validates that the password matches the confirmation
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
# Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange
# Generates an authentication token for the user
change AshAuthentication.GenerateTokenChange
end
create :sign_in_with_magic_link do
description "Sign in or register a user with magic link."
argument :token, :string do
description "The token from the magic link that was sent to the user"
allow_nil? false
end
upsert? true
upsert_identity :unique_email
upsert_fields [:email]
# Uses the information from the token to create or sign in the user
change AshAuthentication.Strategy.MagicLink.SignInChange
metadata :token, :string do
allow_nil? false
end
end
action :request_magic_link do
argument :email, :ci_string do
allow_nil? false
end
run AshAuthentication.Strategy.MagicLink.Request
end
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy action(:add_cookie) do
authorize_if actor_present()
authorize_if relates_to_actor_via(:id)
end
policy always() do
forbid_if always()
end
end
attributes do
uuid_primary_key :id
attribute :email, :ci_string do
allow_nil? false
public? true
end
attribute :hashed_password, :string do
sensitive? true
end
attribute :cookie, :string do
sensitive? true
end
attribute :confirmed_at, :utc_datetime_usec
end
identities do
identity :unique_email, [:email]
end
end
FYI
I generated a project with:
sh <(curl 'https://ash-hq.org/install/bubblelog?install=phoenix') \
&& cd bubblelog && mix igniter.install ash ash_phoenix \
ash_json_api ash_postgres ash_authentication \
ash_authentication_phoenix ash_admin ash_oban oban_web \
live_debugger mishka_chelekom tidewave ash_ai \
--auth-strategy password --auth-strategy magic_link --yes \
&& mix ash.setup