Is it possible to convert policies expression to Ecto expressions?

Kinda of a weird request but I'm wondering if it's possible to resolve the policies expressions so I can reuse them in a regular Ecto query. I started with this code but I'm new to Ash and a bit lost here:
query = Ash.Query.for_read(MyApp.Resource, :my_query, actor: actor)

authorizer = %Ash.Policy.Authorizer{
actor: actor,
resource: query.resource,
action: query.action
}

{:filter, _authorizer, filter} =
Ash.Policy.Authorizer.strict_check(authorizer, %{
domain: MyApp.Domain,
query: query,
changeset: nil
})

# add_filter_expression doesn't work due to missing __ash_bindings__ field
AshSql.Filter.add_filter_expression(query, filter) |> dbg
query = Ash.Query.for_read(MyApp.Resource, :my_query, actor: actor)

authorizer = %Ash.Policy.Authorizer{
actor: actor,
resource: query.resource,
action: query.action
}

{:filter, _authorizer, filter} =
Ash.Policy.Authorizer.strict_check(authorizer, %{
domain: MyApp.Domain,
query: query,
changeset: nil
})

# add_filter_expression doesn't work due to missing __ash_bindings__ field
AshSql.Filter.add_filter_expression(query, filter) |> dbg
I'm aware that's private but that would ease the migration process. I can give more context if needed.
43 Replies
ZachDaniel
ZachDaniel•4mo ago
Does it have to be just the relevant policy expressions? It might be worth stepping back and figuring out what the main thing you're trying to accomplish is
Leandro Pereira
Leandro PereiraOP•4mo ago
Hey @Zach Daniel So we have to add some new permissions in a bunch of existing contexts and ecto queries. Migrating to Ash resources and actions is not viable at the moment so the idea I'm trying to validate here is creating resources with attributes and the policies we need then extract out the underlying ecto conditions to use in those existing ecto queries. I know that's not the usual use-case but I found Ash Authz to have the best engine compared to other libraries and we could migrate to actions gradually But I'm still learning how policies work so that might not even be doable
ZachDaniel
ZachDaniel•4mo ago
Gotcha 🤔 I mean, its likely to be possible, but YMMV There are some easier ways to go about it though that might work better The first thing that comes to mind is to use Ash.data_layer_query(query) which will give you a query back you can run Which will have policies and any other rules included You could then take that query and use Repo.all etc.
Leandro Pereira
Leandro PereiraOP•4mo ago
the problem is that I don't have a Ash.Query I do have instead regular Ecto queries in a context
ZachDaniel
ZachDaniel•4mo ago
You could start your queries w/ the Ash query like:
Resource
|> Ash.Query.for_read(:read, ...)
|> Ash.data_layer_query()
|> case do
{:ok, %{query: query}} ->
from row in query, ...
end
Resource
|> Ash.Query.for_read(:read, ...)
|> Ash.data_layer_query()
|> case do
{:ok, %{query: query}} ->
from row in query, ...
end
Leandro Pereira
Leandro PereiraOP•4mo ago
sorry my initial example was misleading let me give you a better example suppose we're introducing a new permission system wide to check if "something" is owned by current user like so:
defmodule MyApp.Domain do
def foo(scope) do
Repo.all(
from ...
where: author_id == scope.current_user_id
)
end
end
defmodule MyApp.Domain do
def foo(scope) do
Repo.all(
from ...
where: author_id == scope.current_user_id
)
end
end
the actual permissions are way more complex and we're talking about a lot of functions so the idea was to define Ash resources to leverage its Authz engine like so:
defmodule MyApp.Domain.Resource do
attributes...

policies do
# complex policies here
end
end
defmodule MyApp.Domain.Resource do
attributes...

policies do
# complex policies here
end
end
but then we'd need a way to apply those policies in raw ecto queries:
defmodule MyApp.Domain do
policies_clauses = fetch_policies_clauses(MyApp.Domain.Resource)

def foo(scope) do
Repo.all(
from ...
where: ^policies_clauses
)
end
end
defmodule MyApp.Domain do
policies_clauses = fetch_policies_clauses(MyApp.Domain.Resource)

def foo(scope) do
Repo.all(
from ...
where: ^policies_clauses
)
end
end
ZachDaniel
ZachDaniel•4mo ago
So you wouldn't have one resource per table? Its just in the abstract in some way?
Leandro Pereira
Leandro PereiraOP•4mo ago
mostly one resource per table but I don't think that's a problem? the thing is... I've considered bodyguard, let_me, permit, etc, etc and found Ash policies to be better but migrating all those context functions/queries to Ash actions (to use policies automatically) is quite inviable at the moment so I'm trying to leverage only policies while keeping the original Ecto queries, does it make sense? 😄
ZachDaniel
ZachDaniel•4mo ago
Yeah it makes sense I'm just trying to find a reasonable way to arrive at that point Try Ash.DataLayer.filter instead of this: AshSql.Filter.add_filter_expression(query, filter)
Leandro Pereira
Leandro PereiraOP•4mo ago
how can I convert a ecto query to ash?
ecto_query = from ...
ash_query = # TODO
Ash.DataLayer.filter(ash_query, filter, resource)
ecto_query = from ...
ash_query = # TODO
Ash.DataLayer.filter(ash_query, filter, resource)
ZachDaniel
ZachDaniel•4mo ago
Ah, right you cannot but Ash.DataLayer.filter doesn't take an Ash query You may need to set it up though Sorry, just not really a use case I'd considered, but I'm sure you can get there
Leandro Pereira
Leandro PereiraOP•4mo ago
no worries thanks for the help! I know that's not usual lol
ZachDaniel
ZachDaniel•4mo ago
So, i know you are talking about not starting with an ash query, but ultimately i still think that you should, and you can then modify an existing query
Leandro Pereira
Leandro PereiraOP•4mo ago
so ash resources can take ecto queries? I think I saw something about it in the docs
ZachDaniel
ZachDaniel•4mo ago
Only kind of well, no
Leandro Pereira
Leandro PereiraOP•4mo ago
I mean could I create actions without modifying the ecto queries?
ZachDaniel
ZachDaniel•4mo ago
query = Ash.Query.for_read(MyApp.Resource, :my_query, actor: actor)

case Ash.can(query, opts[:actor],
return_forbidden_error?: true,
maybe_is: false,
pre_flight?: false,
filter_with: :filter,
run_queries?: false,
alter_source?: true
) do
{:ok, true} ->
{:ok, your_ecto_query}

{:ok, true, query} ->
{:ok, Ash.Query.data_layer_query(query, initial_query: your_ecto_query)}

{:ok, false, error} ->
{:error, error}

{:error, error} ->
{:error, error}
end
query = Ash.Query.for_read(MyApp.Resource, :my_query, actor: actor)

case Ash.can(query, opts[:actor],
return_forbidden_error?: true,
maybe_is: false,
pre_flight?: false,
filter_with: :filter,
run_queries?: false,
alter_source?: true
) do
{:ok, true} ->
{:ok, your_ecto_query}

{:ok, true, query} ->
{:ok, Ash.Query.data_layer_query(query, initial_query: your_ecto_query)}

{:ok, false, error} ->
{:error, error}

{:error, error} ->
{:error, error}
end
Something like this may work
Leandro Pereira
Leandro PereiraOP•4mo ago
yeah but that would require writing the actions sorry my initial example was really misleading (was a piece of code I was testing and didn't realize it was not aligned with my question) I don't have a query = Ash.Query.for_read(MyApp.Resource, :my_query, actor: actor) because I don't have any actions defined in that resource
ZachDaniel
ZachDaniel•4mo ago
Without actions, you really won't be able to do this
Leandro Pereira
Leandro PereiraOP•4mo ago
if I could do something like:
actions do
read :my_query, do: raw_ecto_query
end
actions do
read :my_query, do: raw_ecto_query
end
that would be useful but I don't think that would work right
ZachDaniel
ZachDaniel•4mo ago
but just having actions doesn't mean that you have to call them Actions are just descriptions they can be used any numbe of ways If you want to use policies, it is always going to be in the context of a resource and an action, you can't get around that part
Leandro Pereira
Leandro PereiraOP•4mo ago
gotcha ultimately we'd have all resources and actions well defined but getting there is the problem
ZachDaniel
ZachDaniel•4mo ago
🤔 I don't see what the trouble is if you can define policies why can't you define a single read action to apply them to? You don't have to define the whole world of possible actions
Leandro Pereira
Leandro PereiraOP•4mo ago
the new permissions the system must take are gonna be applied pretty much everywhere
ZachDaniel
ZachDaniel•4mo ago
actions do
defaults [:read]
end
actions do
defaults [:read]
end
thats all you need with one read action on the resource, you can then apply policies to it
Leandro Pereira
Leandro PereiraOP•4mo ago
I have this situation: - Context A - Function A, B, C - Context B - Function A, B, C and so on (each functions is an ecto query) in order to use policies and actions I'd have to rewrite each one of them as action for more context we already have a couple resources but the majority of the system is currently non-Ash
ZachDaniel
ZachDaniel•4mo ago
🤔 I don't understand why you have to do that Do each of those functions have different policies? Lets rephrase You want to use Ash.Resource just for its policies, right?
Leandro Pereira
Leandro PereiraOP•4mo ago
yep
ZachDaniel
ZachDaniel•4mo ago
So you're going to have to define a resource
defmodule MyApp.Foo.Bar do
use Ash.Resource


policies do
...
end
end
defmodule MyApp.Foo.Bar do
use Ash.Resource


policies do
...
end
end
Right?
Leandro Pereira
Leandro PereiraOP•4mo ago
y
ZachDaniel
ZachDaniel•4mo ago
What is an example of one such policy you might write on the resource? or rather, what would that resource look like in your perfect world?
Leandro Pereira
Leandro PereiraOP•4mo ago
something along these lines (there are a couple other rules but the point is that those rules are being created now and need to be applied pretty much everywhere)
policies do
policy action_type(:read) do
authorize_if accessing_from(AnotherResource, :relationship)
authorize_if expr(visibility == "private" and author_id == ^actor([:id]))
authorize_if expr(visibility == "group" and ^actor([:id]) in group_actor_ids)
forbid_unless MyApp.ComplexRule
end
end
policies do
policy action_type(:read) do
authorize_if accessing_from(AnotherResource, :relationship)
authorize_if expr(visibility == "private" and author_id == ^actor([:id]))
authorize_if expr(visibility == "group" and ^actor([:id]) in group_actor_ids)
forbid_unless MyApp.ComplexRule
end
end
the reason I'm trying to use Ash policies is due to its capability to enforce permissions on relationships, fields, the can? functions, enforcing data security, and so on note that those rules should be applied to 10 different resources and those rules do not exist yet (that's what I'm trying to achieve) so what I'm getting here is that in order to use those policies I'd have to rewrite existing ecto queries (thousands of lines) as ash actions, right? some queries are dead simple but many of them are composed of fragments, dynamic parts, joins, etc, etc in other words, the options seems to be: 1) use bodyguard, let_me, or permit instead of Ash authz to define and enforce those new permissions in existing contexts, queries, templates 2) use Ash Authz (policies) but need to write Ash resources and rewrite Ecto queries as Ash actions
ZachDaniel
ZachDaniel•4mo ago
No You don't have to rewrite all those ecto queries to Ash actions well...... You want to use some pretty advanced stuff like accessing_from that requires a context to be set about where you are loading the data from but let me jsut make my point regardless and maybe it will get you where you want to go
Leandro Pereira
Leandro PereiraOP•4mo ago
it might need more complex constructs but so far the most complex one is accessing_from
ZachDaniel
ZachDaniel•4mo ago
defmodule MyApp.Foo.Bar do
use Ash.Resource

defaults do
actions [:read)
end

policy action_type(:read) do
authorize_if accessing_from(AnotherResource, :relationship)
authorize_if expr(visibility == "private" and author_id == ^actor([:id]))
authorize_if expr(visibility == "group" and ^actor([:id]) in group_actor_ids)
forbid_unless MyApp.ComplexRule
end
end
defmodule MyApp.Foo.Bar do
use Ash.Resource

defaults do
actions [:read)
end

policy action_type(:read) do
authorize_if accessing_from(AnotherResource, :relationship)
authorize_if expr(visibility == "private" and author_id == ^actor([:id]))
authorize_if expr(visibility == "group" and ^actor([:id]) in group_actor_ids)
forbid_unless MyApp.ComplexRule
end
end
With a resource like that, and a helper like this:
def authorize_as_ash_action(ecto_query, resource, action, actor) do
query = Ash.Query.for_read(resource, action, actor: actor)

case Ash.can(query, opts[:actor],
return_forbidden_error?: true,
maybe_is: false,
pre_flight?: false,
filter_with: :filter,
run_queries?: false,
alter_source?: true
) do
{:ok, true} ->
{:ok, ecto_query}

{:ok, true, query} ->
{:ok, Ash.Query.data_layer_query(query, initial_query: ecto_query)}

{:ok, false, error} ->
{:error, error}

{:error, error} ->
{:error, error}
end
end
def authorize_as_ash_action(ecto_query, resource, action, actor) do
query = Ash.Query.for_read(resource, action, actor: actor)

case Ash.can(query, opts[:actor],
return_forbidden_error?: true,
maybe_is: false,
pre_flight?: false,
filter_with: :filter,
run_queries?: false,
alter_source?: true
) do
{:ok, true} ->
{:ok, ecto_query}

{:ok, true, query} ->
{:ok, Ash.Query.data_layer_query(query, initial_query: ecto_query)}

{:ok, false, error} ->
{:error, error}

{:error, error} ->
{:error, error}
end
end
You now have a setup that can apply those Ash policies to any given ecto query
query = from row in Foo,
...

case authorize_as_ash_action(query, Some.Resource, :read, current_user) do
{:ok, ecto_query} -> Repo.all(ecto_query)
end
query = from row in Foo,
...

case authorize_as_ash_action(query, Some.Resource, :read, current_user) do
{:ok, ecto_query} -> Repo.all(ecto_query)
end
There would probably be some kinks to workout with that pattern But it could be done For accessing_from though, I'm not sure how you're expecting taht to work. Are you using like Repo.preload?
Leandro Pereira
Leandro PereiraOP•4mo ago
accessing_from here is used to allow accessing data from a parent resource
ZachDaniel
ZachDaniel•4mo ago
Ah, so thats not how it works in Ash Accessing from is set when you load from a source resource If you want to allow accessing from a parent resource you'd use an expression policy for example i.e authorize_if expr(parent_id == ...)
Leandro Pereira
Leandro PereiraOP•4mo ago
I could be wrong tho that specific line wasn't written by me but thanks I'm gonna double check did an initial pass with authorize_as_ash_action and it did work with a simple forbid_if always()
ZachDaniel
ZachDaniel•4mo ago
🔥
Leandro Pereira
Leandro PereiraOP•4mo ago
actually I think it's not working 🤔
defmodule MyApp.AshForm do
use Ash.Resource, ...

attributes do
uuid_primary_key :id
attribute :title, :string
end

policies do
policy action_type(:read) do
forbid_unless expr(title == "allow")
end
end

actions do
defaults [:read]
end
end
defmodule MyApp.AshForm do
use Ash.Resource, ...

attributes do
uuid_primary_key :id
attribute :title, :string
end

policies do
policy action_type(:read) do
forbid_unless expr(title == "allow")
end
end

actions do
defaults [:read]
end
end
test "check ash policies (deny)" do
ecto_query =
from f in MyApp.Form,
where: f.title == "deny"

assert {:error, _} = authorize_as_ash_action(ecto_query, MyApp.AshForm, :read, %{})
end

test "check ash policies (allow)" do
ecto_query =
from f in MyApp.Form,
where: f.title == "allow"

assert {:ok, _} = authorize_as_ash_action(ecto_query, MyApp.AshForm, :read, %{})
end
test "check ash policies (deny)" do
ecto_query =
from f in MyApp.Form,
where: f.title == "deny"

assert {:error, _} = authorize_as_ash_action(ecto_query, MyApp.AshForm, :read, %{})
end

test "check ash policies (allow)" do
ecto_query =
from f in MyApp.Form,
where: f.title == "allow"

assert {:ok, _} = authorize_as_ash_action(ecto_query, MyApp.AshForm, :read, %{})
end
The first test is passing but the second one fails. In the second test I see:
ecto_query #=> #Ecto.Query<from f0 in MyApp.Form, where: f0.title == "allow">
ash_query #=> #Ash.Query<resource: MyApp.AshForm, action: :read>
ecto_query #=> #Ecto.Query<from f0 in MyApp.Form, where: f0.title == "allow">
ash_query #=> #Ash.Query<resource: MyApp.AshForm, action: :read>
the error contains:
{
:error,
%Ash.Error.Forbidden.Policy{
filter: nil,
resource: MyApp.AshForm,
action: %Ash.Resource.Actions.Read{
name: :read,
...,
filter: nil,
filters: [],
arguments: [],
metadata: [],
modify_query: nil,
primary?: true,
},
policies: [%Ash.Policy.Policy{condition: [{Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]}], policies: [%Ash.Policy.Check{check: {Ash.Policy.Check.Exp
ression, [expr: title == "allow"]}, check_module: Ash.Policy.Check.Expression, check_opts: [expr: title == "allow", access_type: :filter], type: :forbid_unless}], bypass?: nil, descripti
on: nil, access_type: :filter}],
class: :forbidden,
subject: #Ash.Query<resource: MyApp.AshForm, action: :read>,
facts: %{
false => false,
true => true,
{Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]} => true
},
for_fields: nil,
solver_statement: {:and, {Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]}, {:or, {:and, {Ash.Policy.Check.ActionType, [type: [:read], access_type: :fi
lter]}, false}, {:not, {Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]}}}},
context_description: nil,
must_pass_strict_check?: false,
policy_breakdown?: false
}
}
{
:error,
%Ash.Error.Forbidden.Policy{
filter: nil,
resource: MyApp.AshForm,
action: %Ash.Resource.Actions.Read{
name: :read,
...,
filter: nil,
filters: [],
arguments: [],
metadata: [],
modify_query: nil,
primary?: true,
},
policies: [%Ash.Policy.Policy{condition: [{Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]}], policies: [%Ash.Policy.Check{check: {Ash.Policy.Check.Exp
ression, [expr: title == "allow"]}, check_module: Ash.Policy.Check.Expression, check_opts: [expr: title == "allow", access_type: :filter], type: :forbid_unless}], bypass?: nil, descripti
on: nil, access_type: :filter}],
class: :forbidden,
subject: #Ash.Query<resource: MyApp.AshForm, action: :read>,
facts: %{
false => false,
true => true,
{Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]} => true
},
for_fields: nil,
solver_statement: {:and, {Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]}, {:or, {:and, {Ash.Policy.Check.ActionType, [type: [:read], access_type: :fi
lter]}, false}, {:not, {Ash.Policy.Check.ActionType, [type: [:read], access_type: :filter]}}}},
context_description: nil,
must_pass_strict_check?: false,
policy_breakdown?: false
}
}
changing to:
authorize_if expr(title == "allow")
authorize_if expr(title == "allow")
fails the first test and the second one pass so I might be using policies incorrectly here
ZachDaniel
ZachDaniel•4mo ago
The expressions should result in a modified query though, not in a forbidden error oh, yeah you're using it wrong in that first example make sure to read the policies guide policies default to forbidden, and checks apply from top to bottom something has to produce an authorized result for each policy, which forbid_unless cannot do and each policy that applies to a request has to pass
Leandro Pereira
Leandro PereiraOP•4mo ago
hey Zach thanks for the support! I think I have enough to continue here now
ZachDaniel
ZachDaniel•4mo ago
🥳 LMK if you run into any issues We can also likely enhance this and make it a core tool at some point like authorize_data_layer_query

Did you find this page helpful?