Trying to grok ash-authentication -- I keep getting forbidden

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

Why do you have this? It means ‘always apply - always forbid’ so it forbids everything.
I suppose you want to trigger the forbidden error if the action is not authorized. In that case, not super sure, but I think you can move forbid_if always() to the action policy. So that if the authorize_if checks are false then the forbid_if will be evaluated and forbid the action.

Thanks. It’s there because that’s what was generated when I first created the project. I think I got it now. I’ll remove it and go from there.

1 Like

Personally I would rather that default policy is loosey-goosey i.e. everything is allowed. That way they wouldn’t hinder initial development but still inform that they exist. But the hindrance is basically insignificant so it’s not that important. :person_shrugging:

I can’t remember, there must be some reasoning why the default is forbid always. :thinking: Zach will know. :grinning_face_with_smiling_eyes:

The default behavior is that everything is allowed, until you add the policy authorizer at which point nothing is allowed by default, for security reasons. If you add a blanket

policy always() do
  authorize_if always()
end

then any request w/ no other policies that apply will be authorized.

2 Likes