Update 12/18/22: "Part 2" notes/changes added at the bottom.
Based on this post by Peter Ullrich, adapted to use the experimental Janus authorization library.
I've been working on an experimental authorization library over the last few days, and so was pleasantly surprised when Peter posted about building a Role and Permission System. I jumped at the opportunity to see how Janus could "slot in" to the architecture that he presented.
I'm pretty happy with the result! I've posted only those modules that were changed from his presented code below. Here's a summary of changes:
RAP.Permissions
becomesRAP.Policy
and implements theJanus.Policy
callbacks. Note that thebefore_policy_for
isn't actually implemented in Janus yet (this rewrite inspired it).user_has_permission?
is removed in favor ofauthorize
,any_authorized?
, andauthorized
from Janus.- Virtual field
:permissions
added to users as a convenience -- it merges the user's role permissions and their custom permissions. SeeRAP.Users.with_permissions/1
. RAPWeb.Plugs.CheckPermissions
becomesRAPWeb.Plugs.LoadAndAuthorizeResources
and extends/changes the presented API slightly. It now loads and authorizes the individual resource (by default) or all resources the user is allowed to perform the given action on (ifload: :many
is used).
As I see it, adding Janus into the mix gets a few benefits with minimal additional code.
Permissions can now be as granular as desired.
For instance, a customer portal may be added that allows customers to read their own invoices. This could be done by adding a policy_for
for a RAP.Customer
:
def policy_for(policy, %RAP.Customer{} = customer) do
policy
|> allow(:read, Invoice, where: [customer_id: customer.id])
end
Or if you wanted customers to share in the same permissions system, you could add an extra definition of with_permissions
:
defp with_permissions(policy, %RAP.Customer{id: id, permissions: permissions}, permissions, schema) do
permissions
|> Map.get(permission, [])
|> Map.reduce(policy, fn
"read_own", policy ->
allow(policy, "read_own", schema, where: [customer_id: id])
action, policy ->
allow(policy, action, schema)
end)
end
Query Restriction with minimal added effort. Peter mentioned that part 2 of his series will cover query restriction. The example above shows how this could be applied to customers viewing resources that they own.
Peter gives another example of internal employees being able to see salary data for external employees. There are a few ways to add this, but here's one example:
def policy_for(policy, %RAP.User{} = user) do
policy
|> with_salary_permissions(user)
|> # ...
end
defp with_salary_permissions(policy, %RAP.User{type: :internal, permissions: %{"salaries" => actions}}) do
Map.reduce(actions, policy, fn action, policy ->
allow(policy, action, RAP.Salary, where: [employee: [type: :external]])
end)
end
defp with_salary_permissions(policy, _user), do: policy
The above special-cases the application of permissions for "salaries"
by limiting access for internal employees to only those salaries associated with an external employee.
Data loading for free. This is the primary reason I wrote Janus to begin with -- When query restrictions come into play, you often end up having to duplicate your restriction logic between authorizing a single resource and querying many resources. Janus gives you both. Using the salary example above:
# Authorize a single resource.
salary = ... # some external employee salary
{:ok, ^salary} = RAP.Policy.authorize(salary, "read", internal_employee)
# Load all salaries the employee is allowed to read.
salaries = RAP.Salary |> RAP.Policy.authorized("read", internal_employee) |> Repo.all()
# Load the 10 most recently updated salaries the employee is allowed to read.
# Note that RAP.Policy.authorized can operate on a schema (RAP.Salary) or a query and can be further composed.
salaries =
RAP.Salary
|> order_by(desc: :updated_at)
|> RAP.Policy.authorized("read", internal_employee)
|> limit(10)
|> Repo.all()
There is now some indirection that may be cause for confusion or error. Janus operates on Ecto schemas, but permissions are stored keyed off of strings like "invoices"
. This means we have to map those strings to the appropriate schema in policy_for
. One possible benefit of this, however, is that a single key could be associated with multiple schemas (perhaps "invoices"
actually applies those permissions to both RAP.Invoice
and RAP.InvoiceLineItem
, for instance).
There's also a bit of a break of context boundaries in the controller, which reference schemas directly in order to load resources. I don't personally think this is problematic, as I consider schema definitions public, but some may object. This is all "user code", however, and could be modified however the programmer sees fit.
Overall, I was really pleased with how easy it was to integrate Janus into the example that Peter gave and I personally think that the benefits it provides are real in this scenario. I'm really eager to read Peter's followup on Query Restriction and plan to revisit this to integrate any new ideas he may present in that post.
I'd be really excited to here anyone's comments, suggestions, or feedback!