zachdaniel

zachdaniel

Creator of Ash

Got a couple things:

The set_actor plug puts the actor in an idiomatic place on the conn. But it doesn’t call Ash.set_actor/1, that is an “opt-in” tool for storing the actor in the process dictionary.

What I would generally suggest (and will be default in Ash 3.0) is to set this in your api:

authorization do
  authorize :by_default
end

Then authorization will always be running unless you explicitly pass authorize?: false. Not passing an actor is equivalent to actor: nil, but in the default setup, not passing an actor is equivalent to authorize?: false.

If there is a “current actor” then I’d set it as the actor. If its an authentication action, then you can do things like authorize_unless actor_present() to only allow calling it without an actor. You could also add forbid_if always() to make it so that it can only be called with authorize?: false. That is a good way to make something internal only (because api clients and things like that can’t pass authorize?: false.

2
Post #11
lud

lud OP

Thanks, I’m moving slowly but it works. I have set the policies to allow users to read their own inventories only.

I removed the default :create action on the inventory and defined it like this, with the relationships:

actions do
    defaults([:read, :update, :destroy])

    create :create do
      accept([:title])
      argument(:owner, :uuid, allow_nil?: false)
      change(manage_relationship(:owner, :owner, type: :append))
    end
end

  relationships do
    belongs_to :owner, User do
      api(Accounts)
      allow_nil?(false)
    end
  end

Because an inventory always need to belong to a user.

And now I can pass the user.id as :owner to my action, from the controller:

  def create(conn, params) do
    user = auth_actor(conn)

    params =
      params
      |> Map.delete("owner")
      |> Map.put(:owner, user.id)

    Inventory
    |> Ash.Changeset.for_create(:create, params, actor: user)
    |> Inventories.create()
    |> on_ok do
      inventory ->
        conn |> put_status(201) |> render("inventory.json", %{inventory: inventory})
    end
  end

  def show(conn, %{"id" => id}) do
    id
    |> Inventory.get_by_id(actor: auth_actor(conn))
    |> on_ok do
      inventory ->
        conn |> put_status(200) |> render("inventory.json", %{inventory: inventory})
    end
  end

Finally I can define my policies in the Inventory resource like this:

  policies do
    policy action_type(:read) do
      authorize_if(relates_to_actor_via(:owner))
    end

    policy action_type(:create) do
      authorize_if(always())
    end
  end

For it to work I had to add the following to my User resource:

  actions do
    defaults([:read])
  end

I guess it’s because the policy checker needs to read from the user resource. Which I do not understand because I pass the actor in Inventory.get_by_id(actor: auth_actor(conn)) and I believe that authorize_if(relates_to_actor_via(:owner)) should only compare the id from the inventory resource to that actor id. But I believe it is because the :owner relationship could point to a FK that is not on the primary key of the user, and that field could not be loaded by default in the actor.

@zachdaniel I’ll answer to this topic as a discover log of your framework. Do not feel obligated to answer. I hope it can help some people following the same path. Though I have a question: with that setup, I get a Ash.Error.Query.NotFound error when the actor is not the actual owner. Is it possible to get a policy error? Because I want users to be able to authorize other users to read and/or change an inventory, via a new resource (called Allowance or something). So I really need to know from the client app if the inventory exists or not when not authorized.

Thank you a lot for your support so far.

Edit: the formatting has a lot of parentheses because for some reason my VSCode adds them. It looks like ElixirLS is always compiling. I found a github issue about this. At some point I stopped fighting and learnt to love the parens :smiley:

Edit: I tried with this but nobody can access the resource:

    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
    end

But I do get a Forbidden error though.

I modified the relates_to_actor_via function to make it print some debug and raise. It does not seem to be called when used by forbid_unless!

0
Post #12
lud

lud OP

@zachdaniel I found this example I’ll try that :slight_smile:

Edit:

Well I tried different combinations but it will not work

    # Results in Not Found
    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
      authorize_if(always())
    end

    # Results in Not Found
    policy action_type(:read) do
      authorize_if(relates_to_actor_via(:owner))
      forbid_if(always())
    end

    # Results in Not Found
    policy action_type(:read) do
      authorize_if(relates_to_actor_via(:owner))
    end

I also tried a custom simple check but it does not receive a changeset like in the Discord example. I get a query though, but with that I would end up building a filter too.

I feel like as the action is defined with get?(true) it should allow to run filters after the entity has been pulled from the database instead of building a filter.

0
Post #13
zachdaniel

zachdaniel

Creator of Ash

Have you read through the policies guide? Policies — ash v3.29.3

0
Post #14
lud

lud OP

Yes but I could not understand how to not use a filter check, except when using a custom check but that check receives a query and not the entity, so it will be a custom fetch in DB and check … so why not just check in the controller.

0
Post #15
zachdaniel

zachdaniel

Creator of Ash

Might also be worth pointing out that we have a JSON:API extension that will do all of the json api work for you. https://ash-hq.org/docs/guides/ash_json_api/latest/tutorials/getting-started-with-json-api

So there are a couple things I think you need to know.

    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
    end

This policy will always fail. A policy is read from top to bottom, and something in the policy must set the status to authorized. If nothing does, then we assume forbidden.

    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
      authorize_if always()
    end

would do it, but what you probably want instead would be

    policy action_type(:read) do
      authorize_if relates_to_actor_via(:owner)
    end
0
Post #16
zachdaniel

zachdaniel

Creator of Ash

Returning a Forbidden when filter checks don’t match actually a security issue, which is why we favor filter checks wherever possible. For security reasons, we hide from the outside world that the thing you tried to read exists. i.e an endpoint like /users?username=foo returning Forbidden when there is a user with that username, and Not Found when there isn’t.

I think in your case the custom check may be the way to go, and/or a manual/basic action or one of the other escape hatches that we offer for running custom behavior.

Generic Actions (more at the bottom of the page): Actions — ash v3.29.3

Manual Actions:

You could also model it as two resources:
one resource that everyone can see, and one that only some of them can see. Then wether or not the user can see the related thing could be a calculation: Calculations — ash v3.29.3

For the simplest case, I think you might even just do something like this and bypass policies:


actions do
  defaults [:read]

  action :get_inventory_by_id do
    get? true
    argument :id, :uuid, allow_nil?: false
    filter expr(id == ^arg(:id)

    prepare fn query, _ -> 
      Ash.Query.after_action(query, fn _query, results -> 
        if YourApi.can?(__MODULE__, :read, data: results) do
          {:ok, results}
        else
          {:error, Ash.Error.Forbidden.exception()}
        end
      end)
    end
  end
end

policies do
  # let anyone run this action
  policy action(:get_inventory_by_id) do
    authorize_if always()
  end
end
0
Post #17
zachdaniel

zachdaniel

Creator of Ash

See the actions guide for more information on lifecycle hooks around actions (i.e Ash.Query.after_action and prepare).

If you are going to be handwriting your controllers, I’d also suggest looking into the code_interface. https://ash-hq.org/docs/guides/ash/latest/topics/code-interface

For that example action above, you could do:

code_interface do
  define_for Inventories
  define :get_by_id, args: [:id]
end

And then in your controller you could then do

Inventory.get_by_id(id)
0
Post #18
lud

lud OP

Yes I have the custom actions from the tutorial! That’s neat.

I understand why policies would fake a not-found result, but I do not think it should be a default, or at least an opt-out would be great.

So I have this:

def show(conn, %{"id" => id}) do
    id
    |> Inventory.get_by_id(actor: auth_actor(conn), verbose?: true, authorize?: true)
    |> on_ok do
      inventory ->
        conn |> put_status(200) |> render("inventory.json", %{inventory: inventory})
    end
  end

And tried with this:

  authorization do
    authorize :always # also tried :by_default
  end

And this:

 actions do
    defaults([:read, :update, :destroy])

    read :by_id do
      argument(:id, :uuid, allow_nil?: false)
      get?(true)
      filter(expr(id == ^arg(:id)))

      prepare(fn query, ctx ->
        Ash.Query.after_action(query, fn query, results ->
          if Inventories.can?(query, ctx.actor, data: results, authorize?: true) do
            {:ok, results}
          else
            {:error, Ash.Error.Forbidden.exception("nope")}
          end
        end)
      end)
    end

I can see this log:

23:27:41.836 request_id=F1wN6pBOa0NzB0wAAAUB [debug] process MyApp.Inventories.Inventory.by_id: 1 Skipping check due to `authorize?: false`

And the check always returns true, it is not forbidding anyone.

I’ll just handle this in the controller for now, but thank you a lot for the support, it’s nice to try a new framework!

0
Post #19
zachdaniel

zachdaniel

Creator of Ash

Whats wrong with writing the custom check as you mentioned originally? Then you don’t have to do it in the controller. The general goal for building with Ash is to put as much of this stuff in the resource, to encapsulate the domain operation happening. Most people using Ash won’t even have controllers because they’ll use an api extension :slight_smile:

1
Post #20