Phoenix 1.3
zimt28 opened this issue Β· comments
With Phoenix 1.3
soon to be released, I was wondering how bodyguard
should be used with it, as we will have more separation and structure (aka contexts) in the new version.
I'll now port my app over to the current 1.3.0-rc.0
and share my experience when I get to authorization, but I was wondering if you already thought about this :)
I actually have given it some thought, and I'm glad you brought it up. :) Check out the readme on the 2.0-dev
branch: https://github.com/schrockwell/bodyguard/tree/2.0-dev and the PR: #22 for proposed changes.
The idea is to move authorization away from schemas (MyApp.MySchema.Policy
) and instead move it up to the context level (MyApp.MyContext.Policy
) to protect each context function. I'm pretty confident in this decision, and think the new policy structure will support it well.
Conversely, scope
callbacks are typically limited to a particular schema type, and so the scoping should probably occur from within the context. I don't like the solution currently on 2.0-dev
, so any suggestions on how to approach that would be appreciated.
Since scoping is very schema-centric, maybe it makes sense to just use the schema module itself?
defmodule MyApp.MyContext.MyModel do
use Ecto.Schema
use Bodyguard.Schema # injects function: scope(user, scope, opts)
schema "my_model" do
# ...
end
def filter(user, scope, params) do # callback for scope/3
# return filtered scope
end
end
MyApp.MyContext.MyModel.scope(user) # scope arg defaults to MyApp.MyContext.MyModel
MyApp.MyContext.MyModel.scope(user, queryable)
MyApp.MyContext.MyModel.scope(user, queryable, param: :value)
def MyApp.MyContext do
alias MyApp.MyContext
def list_my_models(user) do
user
|> MyContext.MyModel.scope
|> Repo.all
end
def list_new_my_models(user) do
query = from mm in MyContext.MyModel, where: mm.status == "new"
user
|> MyContext.MyModel.scope(filter)
|> Repo.all
end
end
I dunno. Bodyguard doesn't really bring a lot to the table any more with regards to scoping, since it's not really doing any time-saving magic, and the callback structure is more mental overhead.
Since this problem lives outside the domain of true authorization checks, I'm inclined just to drop scopes altogether, and leave it up to the app.
One issue I see right now (with authorization) is that contexts shouldn't know about other contexts' schemas. This makes it impossible to pattern match on user
and resource
using their structs unless they're in the same context. So I think we have to break or at least bend the rules a bit to make this work, don't we?
Edit: Would it be a better idea to build one auth* context then and include all policies there? This would keep the "cheating" modules in one place.
One other thing to keep in mind is that people might build auth* into their app, or their interface. This brings up the question what's the best place to do it, I'd guess it would be the application itself as auth* would then work for all interfaces, but then the user would need to be passed with every context function call. In this case canada might just be enought. bodyguard
however just covers the implementation in the interface, right? This approach, compared to the one mentioned before, seems more convenient though.
# In a controller, authorize an action
with :ok <- MyApp.Blog.Policy.authorize(conn, :update_post, post: post) do
# ...
end
What does bodyguard
do here other than getting the current user from conn
and then calling the permit
functions? π€
Please excuse if my questions and suggestions might not make much sense, it's all theory and I haven't played with 1.3
much yet :)
One issue I see right now (with authorization) is that contexts shouldn't know about other contexts' schemas. This makes it impossible to pattern match on user and resource using their structs unless they're in the same context.
I had a good discussion about this on the Elixir slack last night. The consensus was that yes, the schemas should not refer to each other, and to avoid this each context should have their OWN version of the schema with only the fields that context cares about, e.g. MyApp.MySchema.User
and MyApp.MyOtherSchema.User
. Underneath they can refer to the same table in your DB.
So in that case, doing context-centric authorization still works OK, without violating context boundaries.
What does bodyguard do here other than getting the current user from conn and then calling the permit functions?
Basically nothing.
Although now I see even that piece of magic is disingenuous, because of the context boundary problem. Which User
type should live on the conn
?
This brings up the question what's the best place to do it, I'd guess it would be the application itself as auth* would then work for all interfaces, but then the user would need to be passed with every context function call
Maybe instead of a separate Policy
module, the permission checks just live in the context itself. It's already a giant bag of functions, so might as well throw authorization on there.
defmodule MyApp.MyContext do
use Bodyguard.Policy
def permit(user, action, params), do: # ...
end
with :ok <- MyApp.MyContext.authorize(conn, :some_action),
{:ok, result} <- MyApp.MyContext.some_action(), do: # ...
Bodyguard is still a pretty thin (unnecessary?) layer at that point though.
I wonder if a @behaviour
is most appropriate at that point. Specify authorize/3
as a behaviour and inject authorize!/3
and authorize?/3
wrappers for convenience.
Instead of magically unwrapping the assigns[:current_user]
from a the conn
, maybe provide a little macro to override the action/2
:
def MyApp.Web.MyController do
use Phoenix.Web, :controller
require Bodyguard.Controller
# Override action/2 to pull out conn.assigns.current_user as a 3rd arg
extract_action_user()
def index(conn, params, current_user) do # <-- new current_user arg
with :ok <- MyApp.MyContext.authorize(current_user, :list_things),
{:ok, result} <- MyApp.MyContext.list_things(current_user)
do
# ...
end
end
end
The consensus was that yes, the schemas should not refer to each other, and to avoid this each context should have their OWN version of the schema with only the fields that context cares about, e.g. MyApp.MySchema.User and MyApp.MyOtherSchema.User. Underneath they can refer to the same table in your DB.
Right, so let's say we have an %App.Accounts.User{}
we store in conn.assigns
. Now, as we don't want to use schemas across contexts we define a App.Blog.User
schema, which points to the same database table. The problem is that inside of our App.Blog
context, we can't pattern match using %App.Blog.User{}
, as the conn
holds a %App.Accounts.User{}
. The only way I see to do this is to break the rules or have a %App.User{}
.
Maybe instead of a separate Policy module, the permission checks just live in the context itself. It's already a giant bag of functions, so might as well throw authorization on there.
That seems to make sense, as you'd only want to specify checks for functions of the api/context. Having a separate policy file would however make things a bit more structured, every context would then have a context/policy.ex
file, which is nice, too.
I wonder if a @behaviour is most appropriate at that point. Specify authorize/3 as a behaviour and inject authorize!/3 and authorize?/3 wrappers for convenience.
Instead of magically unwrapping the assigns[:current_user] from a the conn, maybe provide a little macro to override the action/2:
That would certainly make the controllers nicer π
Going with the behaviour approach for a moment, here's a fun idea: a first-class Bodyguard.Action
struct to compose authorized actions, just like how Plug.Conn
, or Ecto.Changeset
works:
defmodule Bodyguard.Action do
defstruct [
context: nil,
user: nil,
auth_run?: false,
auth_result: nil,
authorized?: false,
fallback: &(&1)
]
# ...
end
import Bodyguard.Action
alias MyApp.MyContext
act(MyContext) # => %Bodyguard.Action{context: MyContext}
|> put_user(conn.assigns.current_user) # => %{action | user: conn.assigns.current_user}
|> authorize(:list_users) # uses MyContext.authorize/3 callback, sets auth_result, auth_run?, and authorized?
|> run(fn action -> # executes function if authorized, otherwise calls fallback(auth_result)
users = MyContext.list_users(action.user)
render(conn, "index.html", users: users)
end)
Pros:
- Don't have to use it β just use the Policy behaviour directly when that's all you need
- Divorces Plug.Conn logic from actions
- Can construct Actions ahead of time with known defaults, then just append what's needed per-action. For example, you could construct
MyContext |> act() |> put_user(...)
in a controller plug, then authorize-and-run in each action - A convenient extension point for future functionality
OK so I implemented the above interface on 2.0-dev
β take a look. The docs are up-to-date, so mix docs
to get the lay of the land.
Bodyguard.Policy
is now a simple behaviour with one required callback, authorize/3
. Given that assumption, now you can build a ton of functionality around it.
Bodyguard.Action
is the new composable structure for incrementally building up an authorized action.
Per your feedback:
- If you want a separate
policy.ex
file for structure, you can specify it explicitly usingBodyguard.Action.put_policy/2
- You can grab the User for the appropriate context and assign it using
Bodyguard.Action.put_user/2
β no dependency on the Plug.Conn - Controllers can be DRYer by building up the Action via plugs, then getting them back out via
Bodyguard.Conn.get_action/2
orBodyguard.Action.authorize_conn/3
Still thinking about how to best handle scoping. Contemplating a Bodyguard.Schema
or Bodyguard.Scope
behaviour and maybe some simple helpers around it, to be used within context functions.
@zimt28 Take a look at the latest on 2.0-dev
. I think you'll find it meshes nicely with Phoenix 1.3 contexts and is flexible enough to support the different use cases we discussed above.
The API is almost totally different, so refactoring existing policies/scopes to support it will be a bit of work, but I think it's a net positive moving forward from 1.3.
I've redone my app in the 1.3 layout and used Bodyguard from this dev branch, β so far, so good over here.
Beautiful! I'll give it a try this weekend and let you know how it went, but I don't see any issues and love the simplicity :) Well done π
I'd like to bring up a question I've had before again: Should authorization be an application or interface concern?
I think that everyone has to decide on his own, but what should be the suggested way? I will just use authorize!
directly in my context functions, so authorization will be consistent across interfaces and I thinks it makes more sense, as the motivation behind 1.3
is to make clear that "Phoenix is not your application". What do you think?
Should authorization be an application or interface concern?
Since you brought it up, I'm honestly on the fence about this. All the examples I wrote for the docs show auth checks on the interface (e.g. controller actions) which is nice because failure handling is basically an interface concern, so it's easier to follow the "flow" of execution when it stays in the same layer.
There is the benefit of not having to pass around user
everywhere in the contexts, although you will probably be passing in the user
anyway to do something in relation to it, so that is a weak argument (ha!).
It could make testing a tad cleaner because you can test the raw context functions and their side effects independently of the auth checks β although the argument could be made that this is a Bad Idea because of that separation of logic.
I will just use authorize! directly in my context functions, so authorization will be consistent across interfaces and I thinks it makes more sense
This is a really good argument and I will definitely give it a shot when I start converting things over to contexts as well. Once you do the action/2
override in authenticated controllers, it becomes a lot nicer to work with user
on the interface.
Although I would highly recommend looking at the authorize/3
form instead, using with
clauses within your context functions. Gracefully handling the {:error, :unauthorized}
result with a case
or a fallback controller is really nice.
In the end, putting the auth checks directly IN the context works for both methods because you can utilize it within the context, or externally β it's ultimately up to you. I also added the policy
option to use Bodyguard.Context
if you wanted to specify a standalone policy module like how things worked in 1.0.
There is the benefit of not having to pass around user everywhere in the contexts, although you will probably be passing in the user anyway to do something in relation to it, so that is a weak argument (ha!).
For people using scopes (me π) this would probably always be the case.
All the examples I wrote for the docs show auth checks on the interface (e.g. controller actions) which is nice because failure handling is basically an interface concern, so it's easier to follow the "flow" of execution when it stays in the same layer.
Although I would highly recommend looking at the authorize/3 form instead, using with clauses within your context functions. Gracefully handling the {:error, :unauthorized} result with a case or a fallback controller is really nice.
I'm not sure how I'll do this. I'd like to perform the checks inside my context's functions, but I'm not sure whether authorize/3
or authorize!/3
is the way to go. When using my app with a Phoenix interface, authorize!/3
would just throw a Bodyguard.NotAuthorizedError
which can be handled properly, but in order to be interface agnostic, authorize/3
's output would be nicer - this however would require checks (with
) in the controllers again.
this however would require checks (with) in the controllers again.
Could you just put the with
in the context function? It would be an extra level of indentation but you could do it.
def MyApp.Blog do
def create_post(user, params) do
with :ok <- authorize(:create_post, user) do
# ...
end
end
end
So then Blog.create_post/2
could return {:ok, post}
or {:error, :unauthorized}
That's what I want to do, but then I'd have to use with
syntax in the contexts and controllers, otherwise pattern matching in the controllers might fail:
def MyApp.Web.PostController do
def create(conn, %{"post" => post_params}, _user) do
user = nil
post = MyApp.Blog.create_post(user, user_params)
end
end
** (MatchError) no match of right hand side value: {:error, :unauthorized}
What I'm thinking about is the following, this would allow to skip with
in the controllers, leading to much cleaner and less repetitive code:
defimpl Plug.Exception, for: MatchError do
@unauthorized {:error, :unauthorized}
def status(%{term: @unauthorized}), do: 403
def status(_), do: 500
end
Yup, that could work. It's totally up to you β it's a tradeoff of explicitness.
it's a tradeoff of explicitness.
That's my biggest concern yet. The other way I see is to wrap the authorize
calls in Auth.check
or something, which then calls authorize/3
or authorize!/3
, depending on how the inferface has configured it to work.
I think I'll try the approach mentioned above first and then see if it feels right :)
Great. I'm going to wait until the official 1.3.0 release before bumping Bodyguard to 2.0, just in case there are any changes to Phoenix that would warrant a re-think of Bodyguard. But that doesn't seem likely, so I expect the Bodyguard API to be stable.
It's a really helpful discussion! I think authorization with context API is the right direction. I have a question though:
I see the authorize/3
's last parameter is a params
, does that mean I need to pass it even for creation logic? like authorize(:create_post, user, nil)
? Can the API to treat the last parameter as optional? Not sure if callback can do it though
@darkbaby123 Since you're defining the authorize/3
callback, it's up to you to provide default values for params
in the callback definition. All the Bodyguard helpers (authorize!/3
, authorize?/3
, etc) default it to %{}
, so you should do something like def authorize(action, user, params \\ %{})
@schrockwell Thanks! I borrowed part of the policy code from 2.0-dev to my own project. And I choose to add default parameter to authorize!/3
. I'm really looking forward 1.3 to come out then I can switch to Bodyguard 2.0.
@zimt28 I'll leave my 2 cents to call the authorize
in web interface instead of application interface. Because there're some of APIs doesn't need user. If I wrote authorize
in my context then I have to add user
parameter to every context API. It makes the context API less reusable. One example is deleting:
with {:ok, post} = Blog.get_post(user, id),
:ok <- Blog.authorize(:delete_post, user, %{post: post}),
{:ok, _} <- Blog.delete_post(post) # don't need user here
do
# render something
end
Instead of magically unwrapping the assigns[:current_user] from a the conn, maybe provide a little macro to override the action/2:
Typically, I do this by injecting the current_user
(or any current resource) via Plug then overriding the controller action/2
to pull the current_user
from conn.assigns
and injecting it in the action args.
pipeline :browser do
# ...
plug Guardian.Plug.VerifySession
plug Guardian.Plug.LoadResource
plug MyApp.Plug.CurrentAccount
end
defmodule MyApp.Plug.CurrentAccount do
@moduledoc """
Loads the current account into assigns from Guardian.
"""
def init(opts), do: opts
def call(conn, _opts) do
Plug.Conn.assign(conn, :current_account, current_account(conn))
end
defp current_account(conn) do
Guardian.Plug.current_resource(conn)
end
end
Thanks for the helpful discussion.
Hi folks, just a quick update after the above discussions. I shuffled around the API to make things more concise. At a high level, the new Bodyguard.permit/4
and Bodyguard.scope/3
helpers are now the entry points into the API, instead of calling the callbacks directly.
If you've been using 2.0-dev
, here's a summary of changes:
- Behaviours and callbacks have not changed at all
Bodyguard.Policy.authorize/4
helper and friends moved toBodyguard.permit/4
(name change! Callback is stillauthorize
)Bodyguard.Context
has been removedBodyguard.Schema.scope/3
helper moved toBodyguard.scope/3
- In contexts, replace
use Bodyguard.Context
with@behaviour Bodyguard.Policy; import Bodyguard
- In schemas, replace
use Bodyguard.Schema
with@behaviour Bodyguard.Schema
- When doing auth checks, replace
MyContext.authorize(action, user, %{param: value})
withBodyguard.permit(MyContext, action, user, param: value)
(same for bang and boolean variations) - When doing scoping, replace
MyContext.MySchema.scope(query, user, %{param: value})
withBodyguard.scope(query, user, param: value)
params
are now always keyword lists for callers, and converted to maps for callbacks (thanks for the insight, @darkbaby123)
Look at the diffs for the tests helpers and test cases to see some examples. The docs and readme have been updated too, so mix docs
for the latest.
I apologize for making API changes this late in the game. I wouldn't do it if I didn't think it was actively improving the library and making it more flexible for the future. Thanks for understanding.
I'm planning on using 2.0-dev
(or its respective release, once that's out) with Phoenix 1.3-rc2
. Just wanted to check back and ask, what's the status of the refactoring?
@cybrox No API changes planned - go for it!
Wow, that was fast! Thanks, I'll give it a shot! π
Hi everyone β v2.0.0
is live on Hex now! Thanks for all the constructive discussions.