Feedback wanted: Authorization library with composable Ecto queries

Hi friends,

I wanted an authorization utility for an existing project I’m working on where I have a fair amount of permissions logic duplicated between functions that check whether a person can do something to a given resource and query utilities that filter a query based on what a person is allowed to do.

After spending a bit of time looking at existing solutions, I decided to spend a couple days putting something together myself.

Janus is the result.

The library is extremely alpha and not yet released on hex. It’s basically at the “works enough to test in my own project” stage, but I’d like to hear from folks on the approach I’ve taken and whether it’s worth pursuing further. Basically: I (think I) like it and will use it myself, but I’m not sure whether it makes sense to invest in bits that may make it more useful for others.

If you have a spare 5-10 minutes, please consider reading through the example in the README. I’d love to hear any thoughts or feedback. Thanks!

4 Likes

I just looked at Janus, and i love the ease and flexibility for defining rule for security!

I have a bit of concern though, how about the performance of Janus? I look at code for a bit and it seems to depend on DB (via ecto query), so i have concern on latency and throughput of it.

Thanks for taking a look!

As for performance: calls to allows? and forbids? do not hit the database, but operate only on the in-memory resource. filter creates an Ecto query, the performance of which will depend on the indexes, etc. that you have set up for your database — this is not really any different than if you had written the query yourself.

I’ve not benchmarked anything, but I’m fairly certain that performance will be acceptable for the vast majority of use-cases.

Some quick reactions:

  • what about preloads? filter? could help, but it’s going to take a lot more code than “normal” preloads

  • what about conditions that involve other records? For instance, “admin can only edit a post if it doesn’t have any replies”

Thanks for the questions.

Can you give a quick example of what you mean by this vs. “normal” preloads?

filter does no preloading, it just filters the query using joins. I don’t think it makes sense to couple preloading to authorization, since the concerns are separate — you may need more/less/different associations preloaded than are needed for auth.

allows? also does no preloading for now and just raises if a required association isn’t loaded. This is not optimal though, as your code checking permissions with allows? shouldn’t need to know what needs to be preloaded. It might make sense to accept a repo as an option, so you could do something like

MyPolicy.allows?(someone, :to_do_thing_to, resource, preload_with: Repo)

If you’re checking auth in a loop this could lead to N+1 queries, but in that case you should likely be using filter to filter the set of resources once.

Good question and something I’ve thought about a bit. Two possibilities:

  • Add an escape hatch to allow, forbid, etc. that lets you pass two functions, one that will run for authorizing a single resource and one that will filter a query. This way you can get as complex as needed. This would be very easy to add support for.
policy
|> allow(:edit, Post, allow_func: &allow_edit_post?/3, filter_func: &filter_edit_post/3)
  • Override allows? and filter in your policy module — this is possible now but requires some boilerplate.
def allows?(admin, :edit, %Post{}) do
  #…
end
 
def allows?(actor, action, resource), do: super(actor, action, resource)

def filter(Post, :edit, admin) do
  #…
end

def filter(schema, action, actor), do: super(schema, action, actor)

I think I prefer the first option.

Sticking to the models in the README for concreteness, imagine you wanted to load:

  • the first 20 posts that a user can see
  • all the comments on those that the user can see
  • all the authors on those comments that the user can see

A no-auth approach (the “normal” preloads case) would be straightforward:

from(p in Post, limit: 20)
|> Repo.all()
|> Repo.preload(comments: :author)

Got it.

This kind of API should be fairly straightforward to implement, and I can certainly see the utility:

Post
|> Policy.filter(:read, user, preload: [comments: :author]
|> limit(20)
|> Repo.all()

This would basically be sugar for:

posts_query = Policy.filter(Post, :read, user)
comments_query = Policy.filter(Comment, :read, user)
authors_query = Policy.filter(User, :read, user)

from(p in posts_query,
  limit: 20,
  preload: [comments: {^comments_query, author: ^authors_query}])
|> Repo.all()

This proved a little trickier, but the result is actually pretty dang cool. I’m not quite done implementing this yet but I’m confident it can be done.

To take your example a little further: Let’s say you were implementing user search. In the results, you wanted to display the user’s last thread, the first post in that thread, and user’s latest post in any thread.

Normal users can’t see banned users or archived threads or posts, but moderators can see both.

Here’s how it might look like using Janus:

latest_thread = Thread |> order_by(desc: :inserted_at) |> limit(1)
first_post = Post |> where(index: 0) |> limit(1)
latest_post = Post |> order_by(desc: :inserted_at) |> limit(1)

search_params
|> Accounts.search()
|> Policy.filter(:read, current_user,
  preload_filtered: [
    threads: {latest_thread, posts: first_post},
    posts: latest_post
  ]
)
|> Repo.all()

The result of this would be the users that the current user can read, preloaded with the latest thread created by that user (and that thread’s first post) that the current user can read, and the latest post by that user that the current user can read. So a normal user would see non-banned users and non-archived threads/posts, while a moderator may see banned users and archived threads/posts mixed into the results. This all done with a single query and includes users that may have no threads or posts.

Man, Ecto is really cool.

3 Likes

Loving this :muscle: It’s late here, so I’ll get back to this tomorrow, but have you also looked into implementing query restrictions using “Tenancy” instead of implementing another DDL for Ecto? Personally, I’d want the impact of my RAP as minimal as possible on the “normal” syntax like Ecto’s query language. Also, it should be easily replaceable/removable. That’s why I love our RAP at Remote. You can remove it by removing the plug and that’s it! As a dev, I wasn’t even aware that we had a RAP for a whole year :smile:

See here for example: Multi tenancy with foreign keys — Ecto v3.9.2

Thanks for the suggestion! I’m using this exact approach in one of my own applications (based on the same guide that you linked). I see tenancy as somewhat separate/tangential to authorization, however – in my eyes, tenancy is about creating a hard separation between entities that should never interact, whereas authorization is about giving permission to actors within an entity to operate on resources also within that entity.

There’s certainly a chance I’ve misunderstood you, however, so I’m definitely eager for you to expand on this idea when you are able!

I think that we’re in total agreement here. The DDL used by Janus (in :where clauses, for instance) is meant to be essentially a simplified subset of Ecto’s keyword syntax. It’s simplified so that it can be used to both authorize a single resource and build up an Ecto query. I toyed with the idea of somehow using Ecto.Query.API, but I think that adds too much complexity for too little benefit.

To my eyes, the impact of Janus on the normal way of querying Ecto is extremely small. From outside of your policy module, you don’t do anything differently while building up your query except for appending a call that composes with your query to filter to authorized resources.

Here’s a sketch of what a context function might look like that loads authorized resources:

@doc """
Load the resources!

## Options

  * `:limit` - limit number of results (default 20)
  * `:authorize` - `{action, actor}` tuple to filter query to only those authorized
"""
def load_the_resources(opts \\ []) do
  query =
    MyResource
    |> order_by(desc: :inserted_at)
    |> whatever_other_filter()
    |> limit(Keyword.get(opts, :limit, 20))

  query =
    case opts[:authorize] do
      {action, actor} -> Policy.authorize(query, action, actor)
      nil -> query
    end

  Repo.all(query)
end

I think this approach is similarly replaceable/removable. Since you “own” your policy module and no code outside of that module should reference Janus, the impact of removing/replacing is limited. I’m trying to keep the API very small (currently authorize, any_authorized? and authorized [which I think I’m going to rename to filter_authorized or similar]).

Excited to hear more of your thoughts tomorrow!

@PJUllrich Just read part 2 and updated my gist with notes on how I might implement that using Janus!

I see now what you mean by using the “tenancy” approach! I think it translates nicely in Janus using filter_authorized and a process-dictionary-cached policy:

@operation_actions %{
  all: "read",
  stream: "read",
  update_all: "update",
  delete_all: "delete",
  insert_all: "create"
}

@impl true
def prepare_query(op, query, opts) do
  cond do
    opts[:schema_migration] || opts[:policy] == :ignore ->
      {query, opts}

    policy = opts[:policy] ->
      {RAP.Policy.filter_authorized(query, @operation_actions[op], policy), opts}

    true ->
      raise "expected policy to be set"
  end
end

Ended up really happy with this in my own usage and it’s become a bit of A Thing™. If anyone is able to check out the docs, I’d be extremely appreciative of any feedback. Even better would be if someone had the time and willingness to run the policy generator and use it to define some auth rules for their own project:

defp deps do
  [
    :ex_janus, "~> 0.2.0-alpha.0"
  ]
end
$ mix janus.gen.policy

:heart: