How do you set policies to read through a restricted resource?

For example, each User has_one Profile. Users should be able to read Profiles of other Users only if they are related to the other User. Users should never be able to read other Users.

If I write a policy with relates_to_actor_via on the Profile, this doesn’t work because the related User loads to nil before their Profile loads. My workaround is to allow Users to read related Users but restrict all attributes on the User with field policies.

Is this an anti-pattern? What’s the best way to do this?

1 Like

I’d have to see a more specific example of your set up, but relates_to_user_via doesn’t apply the policies of the relationships in question (otherwise policies would be infinitely recursive).

More specifically, I’m loading the sender’s Profile like so:

Ash.load!(user, [mail_received: [sender: :profile]], actor: user)

And the resources look like this:

# User

field policies do
  field_policy :* do
    authorize_if expr(id == ^actor(:id))
  end
end

policies do
  policy action_type(:read) do
    authorize_if expr(id == ^actor(:id))
    # The line in question is the following. I'd rather not include this, but it seems necessary in my testing:
    authorize_if relates_to_actor_via([:mail_sent, :receiver])
  end
end

relationships do
  has_one :profile, Profile
  has_many :mail_sent, Mail do
    destination_attribute :sender_id
  end
  has_many :mail_received, Mail do
    destination_attribute :receiver_id
  end
end

# Profile

policies do
  policy action_type(:read) do
    authorize_if relates_to_actor_via(:user)
    authorize_if relates_to_actor_via([:user, :mail_sent, :receiver])
  end
end

relationships do
  belongs_to :user, User, allow_nil?: false
end

# Mail

relationships do
  belongs_to :sender, User
  belongs_to :receiver, User
end

I think what I need to see is specifically what you’re trying, what output you’re getting, and how it doesn’t match your expectations. I think you’re making an assumption about how the policies work that may not be correct, so let’s get back to the basics. Show each involved resource, and the way you’re calling them.

Sorry, let me try to be clearer. I understand my own question better now. It has more to do with loading than with policies. Starting over.

Resources:

# User

  policies do
    policy action_type(:read) do
      authorize_if expr(id == ^actor(:id))
    end

    policy always() do
      authorize_if always()
    end
  end

  relationships do
    has_one :profile, Example.Domain.Profile
    has_many :mail_sent, Example.Domain.Mail do
      destination_attribute :sender_id
    end
    has_many :mail_received, Example.Domain.Mail do
      destination_attribute :receiver_id
    end
  end

# Profile

  policies do
    policy action_type(:read) do
      authorize_if relates_to_actor_via(:user)
      authorize_if relates_to_actor_via([:user, :mail_sent, :receiver])
    end

    policy always() do
      authorize_if always()
    end
  end

  relationships do
    belongs_to :user, Example.Accounts.User
  end

# Mail

  policies do
    policy action_type(:read) do
      authorize_if relates_to_actor_via(:sender)
      authorize_if relates_to_actor_via(:receiver)
    end

    policy always() do
      authorize_if always()
    end
  end

  relationships do
    belongs_to :sender, Example.Accounts.User
    belongs_to :receiver, Example.Accounts.User
  end

My goal is to read the Profiles of Users who sent Mail to user.

I figured out the following read which works(!) and is all I need:

iex(36)> Ash.Query.filter(Example.Domain.Profile, user.exists(mail_sent, receiver.id == ^user.id)) |> Ash.read!(actor: user)
[debug] QUERY OK source="profiles" db=1.0ms idle=1959.5ms
SELECT p0."id", p0."user_id" FROM "profiles" AS p0 LEFT OUTER JOIN "public"."users" AS u1 ON p0."user_id" = u1."id" WHERE (exists((SELECT DISTINCT ON (su0."id") su0."id", su0."email" FROM "public"."users" AS su0 INNER JOIN "public"."mails" AS sm1 ON su0."id" = sm1."sender_id" INNER JOIN "public"."users" AS su2 ON sm1."receiver_id" = su2."id" WHERE (su2."id"::uuid = $1::uuid) AND (p0."user_id" = su0."id"))) OR (u1."id"::uuid = $2::uuid)) AND (exists((SELECT sm0."id", sm0."sender_id", sm0."receiver_id" FROM "public"."mails" AS sm0 INNER JOIN "public"."users" AS su1 ON sm0."receiver_id" = su1."id" WHERE (su1."id"::uuid = $3::uuid) AND (u1."id" = sm0."sender_id")))) ["df4011c8-2838-46bc-a42c-8353e8aff206", "df4011c8-2838-46bc-a42c-8353e8aff206", "df4011c8-2838-46bc-a42c-8353e8aff206"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:696
[
  #Example.Domain.Profile<
    user: #Ash.NotLoaded<:relationship, field: :user>,
    __meta__: #Ecto.Schema.Metadata<:loaded, "profiles">,
    id: "766269f1-522d-47e4-abe0-8ca8b7378306",
    user_id: "a3bb29c1-0289-47c2-a477-29e4db4aedb0",
    aggregates: %{},
    calculations: %{},
    ...
  >
]

What I was trying earlier using Ash.load which doesn’t work (sender is nil and nothing in the sender is loaded):

iex(24)> Ash.load(user, [mail_received: [sender: :profile]], actor: user)
[debug] QUERY OK source="users" db=0.4ms idle=1924.8ms
SELECT u0."id", (u0."id"::uuid = $1::uuid)::boolean::boolean FROM "users" AS u0 WHERE (u0."id"::uuid = $2::uuid) ["df4011c8-2838-46bc-a42c-8353e8aff206", "df4011c8-2838-46bc-a42c-8353e8aff206"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:696
[debug] QUERY OK source="mails" db=0.6ms idle=1926.0ms
SELECT m0."sender_id", m0."id", m0."receiver_id" FROM "mails" AS m0 LEFT OUTER JOIN "public"."users" AS u1 ON m0."sender_id" = u1."id" LEFT OUTER JOIN "public"."users" AS u2 ON m0."receiver_id" = u2."id" WHERE ((u1."id"::uuid = $1::uuid) OR (u2."id"::uuid = $2::uuid)) AND (m0."receiver_id"::uuid IN ($3::uuid)) ["df4011c8-2838-46bc-a42c-8353e8aff206", "df4011c8-2838-46bc-a42c-8353e8aff206", "df4011c8-2838-46bc-a42c-8353e8aff206"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:696
[debug] QUERY OK source="users" db=0.1ms idle=1927.5ms
SELECT u0."id", u0."email", (u0."id"::uuid = $1::uuid)::boolean::boolean FROM "users" AS u0 WHERE (u0."id"::uuid = $2::uuid) AND (u0."id"::uuid IN ($3::uuid)) ["df4011c8-2838-46bc-a42c-8353e8aff206", "df4011c8-2838-46bc-a42c-8353e8aff206", "a3bb29c1-0289-47c2-a477-29e4db4aedb0"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:696
{:ok,
 #Example.Accounts.User<
   mail_received: [
     #Example.Domain.Mail<
       receiver: #Ash.NotLoaded<:relationship, field: :receiver>,
       sender: nil,
       __meta__: #Ecto.Schema.Metadata<:loaded, "mails">,
       id: "001892ab-dbf4-444b-97b3-88d74d63937b",
       sender_id: "a3bb29c1-0289-47c2-a477-29e4db4aedb0",
       receiver_id: "df4011c8-2838-46bc-a42c-8353e8aff206",
       aggregates: %{},
       calculations: %{},
       ...
     >
   ],
   mail_sent: #Ash.NotLoaded<:relationship, field: :mail_sent>,
   profile: #Ash.NotLoaded<:relationship, field: :profile>,
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: "df4011c8-2838-46bc-a42c-8353e8aff206",
   email: #Ash.CiString<"b@b">,
   aggregates: %{},
   calculations: %{},
   ...
 >}

So my understanding is that if sender isn’t loaded then neither are its fields, is this right? I guess I was expecting individual fields in sender to be loaded even if the sender itself is forbidden.

ah now I understand. Correct, if you can’t view a record, it is completely hidden. There isn’t a way to have “placeholder” record. Your solution with field policies is a reasonable one at any point. I.e “you can see that a user exists with this id, but no fields” is reasonable.

1 Like