Skip to content

Instantly share code, notes, and snippets.

@zachallaun
Last active December 18, 2022 21:58
Show Gist options
  • Save zachallaun/5696b004436c8f33c4f66d2a7400b534 to your computer and use it in GitHub Desktop.
Save zachallaun/5696b004436c8f33c4f66d2a7400b534 to your computer and use it in GitHub Desktop.
Implementation of an RAP system using the Janus library

RAP System using Janus

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.

Context

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 becomes RAP.Policy and implements the Janus.Policy callbacks. Note that the before_policy_for isn't actually implemented in Janus yet (this rewrite inspired it).
  • user_has_permission? is removed in favor of authorize, any_authorized?, and authorized from Janus.
  • Virtual field :permissions added to users as a convenience -- it merges the user's role permissions and their custom permissions. See RAP.Users.with_permissions/1.
  • RAPWeb.Plugs.CheckPermissions becomes RAPWeb.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 (if load: :many is used).

Benefits of using Janus

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()

Drawbacks of using Janus

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.

Conclusion

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!

defmodule RAP.Policy do
use Janus.Policy
import Ecto.Changeset
before_policy_for :user_with_permissions
def all_permissions() do
%{
"invoices" => ["create", "read", "update", "delete"],
"addresses" => ["read", "update", "delete"]
}
end
@impl true
def policy_for(policy, %RAP.User{} = user) do
policy
|> with_permissions(user, "invoices", RAP.Invoice)
|> with_permissions(user, "addresses", RAP.Address)
end
@impl true
def before_policy_for(:user_with_permissions, policy, %RAP.User{} = user) do
{:cont, policy, RAP.Users.with_permissions(user)}
end
defp with_permissions(policy, %RAP.User{permissions: permissions}, permission, schema) do
permissions
|> Map.get(permission, [])
|> Map.reduce(policy, fn action, policy ->
allow(policy, action, schema)
end)
end
def validate_permissions(changeset, field) do
validate_change(changeset, field, fn _field, permissions ->
permissions
|> Enum.reject(&has_permission?(all_permissions(), &1))
|> case do
[] -> []
invalid_permissions -> [{field, {"invalid permissions", invalid_permissions}}]
end
end)
end
def has_permission?(permissions, {name, actions}) do
exists?(name, permissions) && actions_valid?(name, actions, permissions)
end
defp exists?(name, permissions), do: Map.has_key?(permissions, name)
defp actions_valid?(permission_name, given_action, permissions) when is_binary(given_action) do
actions_valid?(permission_name, [given_action], permissions)
end
defp actions_valid?(permission_name, given_actions, permissions) when is_list(given_actions) do
defined_actions = Map.get(permissions, permission_name)
Enum.all?(given_actions, &(&1 in defined_actions))
end
end
defmodule RAP.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
has_one(:address, RAP.Address)
belongs_to(:role, RAP.Role)
field(:custom_permissions, :map, default: %{})
field(:permissions, :map, virtual: true)
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:role_id, :custom_permissions])
|> validate_required([:role_id])
|> RAP.Policy.validate_permissions(:custom_permissions)
end
end
defmodule RAP.Role do
use Ecto.Schema
import Ecto.Changeset
schema "roles" do
field(:name, :string)
field(:permissions, :map)
has_many(:users, RAP.User)
timestamps()
end
@doc false
def changeset(role, attrs) do
role
|> cast(attrs, [:name, :permissions])
|> validate_required([:name, :permissions])
|> unique_constraint(:name)
|> validate_at_least_one_permission()
|> RAP.Policy.validate_permissions(:permissions)
end
defp validate_at_least_one_permission(changeset) do
validate_change(changeset, :permissions, fn field, permissions ->
if map_size(permissions) == 0 do
[{field, "must have at least one permission"}]
else
[]
end
end)
end
end
defmodule RAP.Users do
alias RAP.Repo
def with_permissions(user) do
user = Repo.preload(user, :role)
permissions =
Map.merge(user.role.permissions, user.custom_permissions, fn _key, p1, p2 ->
Enum.uniq(p1 ++ p2)
end)
%{user | permissions: permissions}
end
def add_role_to_user(user, role_name) do
with {:ok, role} <- get_role_by_name(role_name) do
update_user(user, %{role_id: role.id})
end
end
def add_custom_permission_to_user(user, name, actions) do
custom_permissions = Map.put(user.custom_permissions, name, actions)
update_user(user, %{custom_permissions, custom_permissions})
end
end
defmodule RAPWeb.AddressController do
use RAPWeb, :controller
plug(RAPWeb.Plugs.LoadAndAuthorizeResources,
actions: [
index: {RAP.Address, "read", load: :many},
show: {RAP.Address, "read"},
delete: {RAP.Address, "delete"}
]
)
def index(conn, _params) do
%{assigns: %{resources: addresses}} = conn
# ...
end
def show(conn, _params) do
%{assigns: %{resource: address}} = conn
# ...
end
def delete(conn, _params) do
%{assigns: %{resource: address}} = conn
# ...
end
end
defmodule RAPWeb.Plugs.LoadAndAuthorizeResources do
import Plug.Conn
import Phoenix.Controller, only: [action_name: 1]
alias RAP.Policy
alias RAP.Repo
def init(opts) do
actions =
opts
|> Keyword.fetch!(:actions)
|> Enum.map(fn
{action, {schema, resource_action, opts}} ->
{action, {schema, resource_action, Keyword.put_new(opts, :load, :one)}}
{action, {schema, resource_action}} ->
{action, {schema, resource_action, load: :one}}
end)
[actions: actions]
end
def call(conn, opts) do
{schema, resource_action, action_opts} = get_required_permission(conn, opts)
if conn = load_and_authorize(conn, schema, resource_action, action_opts[:load]) do
conn
else
conn
|> put_status(:forbidden)
|> halt()
end
end
defp load_and_authorize(conn, schema, action, :one) do
with %{query_params: %{"id" => id}} <- conn,
%_{} = resource <- Repo.get(schema, id),
{:ok, resource} <- Policy.authorize(resource, action, get_user(conn)) do
assign(conn, :resource, resource)
else
_ -> nil
end
end
defp load_and_authorize(conn, schema, action, :many) do
user = get_user(conn)
if Policy.any_authorized?(schema, action, user) do
resources = Policy.authorized(schema, action, user) |> Repo.all()
assign(conn, :resources, resources)
else
nil
end
end
defp get_user(conn) do
conn.assigns.current_user
end
defp get_required_permission(conn, opts) do
action = action_name(conn)
opts
|> Keyword.fetch!(:actions)
|> Keyword.fetch!(action)
end
end

Implementing "Part 2" of Peter's RAP mini-series

Link

Summary of changes:

  • Add with_scoped_permissions to RAP.Policy to further limit records available to a user based on their user.role.data_scope.
  • RAP.Repo caches a policy in the process dictionary that it uses with RAP.Policy.filter_authorized/3. The query operation (:all, :insert_all, :update_all, etc.) is mapped to the CRUD action used to define the policy.
  • Using the RAPWeb.Plugs.LoadAndAuthorizeResources plug above would already have correctly scoped things without any changes to RAP.Repo, but it could be easily modified to not load resources and instead just run something like:
user |> RAP.Policy.policy_for() |> RAP.Repo.put_policy()
defmodule RAP.Policy do
# ...
@impl true
def policy_for(policy, %RAP.User{} = user) do
policy
|> with_permissions(user, "invoices", RAP.Invoice)
|> with_scoped_permissions(user, "addresses", RAP.Address)
end
defp with_scoped_permissions(policy, %RAP.User{} = user, permission, schema) do
scope =
case user.role.data_scope do
:internal -> [user: [group: :internal]]
:external_all -> [user: [group: :external]]
:external_one -> [user: [group: :external, id: user.id]]
end
with_permissions(policy, user, permission, schema, where: scope)
end
defp with_permissions(policy, %RAP.User{permissions: permissions}, permission, schema, opts \\ []) do
permissions
|> Map.get(permission, [])
|> Map.reduce(policy, fn action, policy ->
allow(policy, action, schema, opts)
end)
end
end
defmodule RAP.Repo do
use Ecto.Repo,
otp_app: :rap,
adapter: Ecto.Adapters.Postgres
require Ecto.Query
@policy_key {__MODULE__, :policy}
def put_policy(policy) do
Process.put(@policy_key, policy)
end
def get_policy() do
Process.get(@policy_key)
end
@impl true
def default_options(_operation) do
[policy: get_policy()]
end
@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
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment