Unknown"invalid_column_reference" error while attempt to run an upsert action

I’m creating an upsert action, but it returns an error when I try to use it:

Member2
|> Ash.Changeset.for_create(:signup_with_auth, %{email: email}, tenant: group.id)
|> Ash.create()
{
  :error,
  %Ash.Error.Unknown{changeset: "#Changeset<>",
    errors: [%Ash.Error.Unknown.UnknownError{
      error: "** (Postgrex.Error) ERROR 42P10 (invalid_column_reference) there is no unique or exclusion constraint matching the ON CONFLICT specification",
      field: nil, value: nil, splode: Ash.Error, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace<>, class: :unknown}]}
}

Here are the important parts of the resource module:

defmodule Member2 do
  use Ash.Resource,
    domain: GF.Domain,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  attributes do
    attribute :email, :string, public?: true
    attribute :group_id, :integer, public?: true
  end

  actions do
    defaults [:read]

    create :signup_with_auth do
      upsert? true
      upsert_identity :unique_email
      accept [:name, :email]
    end
  end
  
  identities do
    identity :unique_email, [:email]
    # Also tried this:
    # identity :unique_email, [:email, :group_id]
  end
  
  multitenancy do
    strategy :attribute
    attribute :group_id
    global? true
  end
end

There is a similar upsert action elsewhere in my application that works fine, but that resource isn’t multitenant, so I wonder if the problem has something to do with multitenancy. The other thing I considered is the the foreign key constraint that is currently defined in the database. Are there requirements I need to follow for the FK constraint?

:thinking: attribute multi tenancy could play a part, but IIRC we’ve accounted for that when upsetting. Have you run mix ash.codegen? Are there no pending migrations?

No, I didn’t run codegen. When I do, it produces this unique index:

create unique_index(:members, [:group_id, :email], name: "members_unique_email_index")

But I already have a unique index on these columns. It’s somewhat different:

CREATE UNIQUE INDEX members_lower_email_group_id_index
ON members((lower(email::text)) text_ops,group_id int4_ops);

Right, so to upsert on that you’d need to upsert on that exact expression. To do that:

# add a calc
calculate :lower_email, :string, expr(string_downcase(email))

identity :members_unique_email, [:group_id, :lower_email]

You’ll get a compile error telling you to add a calculations to SQL option to express that calculation in raw SQL, then it can generate an appropriate index, and can use the index in upserts etc.

That pretty much got me there. I just had to add this

postgres do
  calculations_to_sql lower_email: "LOWER(email)"
end

Also, I removed the tenant attribute from the identities, like this:

identities do
  identity :members_unique_email, [:lower_email]
end

I had to make some other changes unrelated to this particular issue, but the action was eventually successful. Also, after running ash.codegen and deleting the generated migration file, then running ash.codegen a second time produced no further migration file.

Success!

1 Like

While troubleshooting this issue, I commented out all the field policies. But after restoring them, the action when run in AshGraphql fails with:

[warning] GF.Members.Member2.read
[info] GQL result errors for operation
SignupWithAuth: [
%{code: "forbidden_field", message: "forbidden field",
path: ["signupWithAuth", "result", "email"], fields: [], vars: %{}, locations: [%{line: 5, column: 7}], short_message: "forbidden field"}]

It’s trying to execute the :read action now. Can I get it to use another read action, :read_self, that the actor should has permission to use?

Or maybe the issue is something else. The actor should be able to read that field as long as the record is the actor’s own record.

Sorry, can you give more context on the issue?

Sure, so I want a user to be able to register themselves with an email address and name. That’s the upsert action described in the beginning. But when I run it from AshGraphql, it fails with the above-mentioned error. According to the field policies of this resource, the actor should have access to the email field as long as the record is the actor’s own record. There’s aleady a self-update action that works this way, and it works in AshGraphql without issues. The email attribute is read without issue. But this particular action is an upsert, and it’s giving the error described above.

Here’s the self-update mutation that works:

actions do
   update :update_self do
    accept [:name]
    require_atomic? false

    change(&GF.Members.MemberActions.update_self/2)
    change(&GF.Members.MemberActions.create_snapshot/2)
  end

  read :read_self do
    get? true
    manual GF.Members.CurrentActorRead
  end
end

policies do
    bypass action [:read_self, :update_self] do
      authorize_if expr(^actor(:__struct__) == ^__MODULE__ and id == ^actor(:id))
    end
end

update :update_self_member2, :update_self do
  identity false
  read_action :read_self
end

Can I see the field policies?

Here are the field policies:

  @any_member_fields [
    :joined_group_on,
    :member_type,
    :name,
    :status,
    :title,
    :trial
  ]

  @member_self_fields @any_member_fields ++
                        [
                          :auto_renews,
                          :customer_id,
                          :dues_exempt,
                          :dues_variant_id,
                          :email,
                          :emails_enabled,
                          :emails_paused_until,
                          :left_group_on,
                          :meetup_member_id,
                          :membership_ends_on,
                          :membership_plan_id,
                          :membership_starts_on,
                          :phone_number,
                          :roles,
                          :trial_ends_on,
                          :inserted_at
                        ]

  @admin_only_fields @member_self_fields ++
                       [
                         :banned_datetime,
                         :developer_access,
                         :stripe_customer_id,
                         :linked_account_id,
                         :source
                       ]

  #  Note: field_policies are only considered for _reading_ data.
  field_policies do
    field_policy_bypass @admin_only_fields, {ActiveMemberPolicy, role: :members} do
      authorize_if always()
    end

    field_policy_bypass @member_self_fields, actor_attribute_equals(:__struct__, __MODULE__) do
      authorize_if expr(id == ^actor(:id))
      forbid_if always()
    end

    field_policy @any_member_fields do
      authorize_if {ActiveMemberPolicy, []}
    end
  end

Can I see this custom check? ActiveMemberPolicy

Here is ActiveMemberPolicy:

defmodule GF.Members.ActiveMemberPolicy do
  use Ash.Policy.SimpleCheck

  # This is used when logging a breakdown of how a policy is applied - see Logging below.
  def describe(_) do
    "Member is active and has given role"
  end

  def match?(%_{} = member, %{resource: _resource} = _context, opts) do
    active? =
      case member do
        %{status: :active} -> true
        _other -> false
      end

    cond do
      opts[:role] ->
        :"Elixir.GF.Members.Member".can_take_role_action?(member, opts[:role])

      true ->
        active?
    end
  end

  def match?(_actor, _context, _opts) do
    false
  end
end

Oh, sorry, that doesn’t come into play nvm :slight_smile:

Hmm…okay, could you try to work up a reproduction/issue in that case? I will have to investigate. Perhaps when loading data for an upsert we’re not properly providing the actor? Hard to say.

I was incorrect. In the AshGraphl test, the record did actually get created, but:

  • There is an "errors" object at the root of the response body, that describes a "forbidden_field" of "email".
  • The return "email" is nil, which I suppose is expected if the user didn’t have the ability to read the email. But according to the field policies, the user should have that ability.

Here is the response body:

resp_body #=> %{
  "data" => %{
    "signupWithAuth" => %{
      "errors" => [],
      "result" => %{
        "email" => nil,
        "id" => "vJKvY1GBEP8ALiC",
        "name" => "",
        "status" => "NON_MEMBER"
      }
    }
  },
  "errors" => [
    %{
      "code" => "forbidden_field",
      "fields" => [],
      "locations" => [%{"column" => 7, "line" => 5}],
      "message" => "forbidden field",
      "path" => ["signupWithAuth", "result", "email"],
      "short_message" => "forbidden field",
      "vars" => %{}
    }
  ]
}

I’ll try to get a reproduction in a new, separate project.

1 Like

Here’s a working reproduction:

Here’s the resource module: ash_playground_one/lib/one/user.ex at main · moxley/ash_playground_one · GitHub

This reproduction uses the latest versions of ash, ash_postgres, and ash_graphql, and these newer version show more information about the policy failure (see comments in the code).

It appears the policy doesn’t like the fact that there’s no actor. I see the problem, but I’m not sure what that the solution might be.

UPDATE: Eliminated multitenancy as a contributing factor. The example above has been updated.
UPDATE 2: Eliminated unique index name as a contributing factor.
UPDATE 3: Eliminate lower() index constraint as a contributing factor.

When you say it doesn’t like the fact that there is no actor…wait. Of course. There is no actor because you’re signing them up. :thinking:

I think what you’ll want to do, is make exceptions in your policies for this specific action. i.e you can see the email of a user you just signed up.

I think what you’ll want to do, is make exceptions in your policies for this specific action. i.e you can see the email of a user you just signed up.

I’m not sure how to go about doing that. Do you mean, make an exception within the field policies?

Like this?

  field_policies do
    field_policy [:email, :name] do
      authorize_if action(:signup)
    end
  end

The test still fails on the field policy, because internally the :read action is being called. It’s the :read action that’s causing the error. How do I allow reading fields for the :read action for this case without exposing fields to everyone else?

Ah, okay, right. That is a problem. This is a pretty special case, but obviously needs a solution. I’m considering something like authorize_if just_created_by(:signup), and we can set context on the changeset to support that after creating a record.

Can you open an issue w/ this information? I’ll add this tomorrow.