defmodule Authorizir do
@moduledoc ~S"""
Ecto-backed Authorization Library for Elixir Applications
See [README](README.md) for a description of the mathematical model used as
the basis of this system.
## Usage
Imagine you are creating an app that handles online ordering.
First, create your app's authorization module, configuring it with your
application repository:
```elixir
defmodule Auth do
use Authorizir, repo: Repo
end
```
Users of the application might be organized into a hierarchy as follows (note
that an employee can also be a customer):
```mermaid
graph TD
*[Users *] --> E[Employees]
* --> C[Customers]
E --> A[Admins]
E --> M[Marketing]
E --> F[Finance]
E --> S[Shipping and Fulfillment]
A --> Bob
M --> Bob
M --> Jane
F --> Amanda
S --> George
S --> Beth
C --> Amanda
C --> George
C --> John
```
We have two types of Subject entities represented here; "Organizational Units"
represent groups of users such as internal departments and customers, while
"Users" represent the individual system accounts. Each of these are
represented with Ecto schemas in our app, and we include the
`Authorizir.Subject` behavior in the modules, so that they can participate in
the Subject hierarcy.
First we add the necessary migrations by running `mix ecto.gen.migraion
add_org_units_and_users` and editing the resulting migration file:
```elixir
defmodule AddOrgUnitsAndUsers do
use Ecto.Migration
import Authorizir.Migrations, only: [apply_subject_hierarchy: 2]
create table("org_units") do
add :name, :string, null: false
timestamps()
end
apply_subject_hierarchy("org_units", id_field: :id)
create table("users") do
add :name, :string, null: false
timestamps()
end
apply_subject_hierarchy("users", id_field: :id)
end
```
```elixir
defmodule OrgUnit do
use Ecto.Schema
use Authorizir.Subject
schema "org_units" do
field :name, :string
end
end
defmodule User do
use Ecto.Schema
use Authorizir.Subject
schema "users" do
field :name, :string
end
end
```
You can create the hierarchy as:
```elixir
{:ok, employees} = %OrgUnit{name: "Employees"} |> Repo.insert()
{:ok, customers} = %OrgUnit{name: "Customers"} |> Repo.insert()
{:ok, admins} = %OrgUnit{name: "Admins"} |> Repo.insert()
:ok = Auth.add_child(employees.id, admins.id, Subject)
{:ok, marketing} = %OrgUnit{name: "Marketing"} |> Repo.insert()
:ok = Auth.add_child(employees.id, marketing.id, Subject)
{:ok, finance} = %OrgUnit{name: "Finance"} |> Repo.insert()
:ok = Auth.add_child(employees.id, finance.id, Subject)
{:ok, shipping} = %OrgUnit{name: "Shipping and Fulfillment"} |> Repo.insert()
:ok = Auth.add_child(employees.id, shipping.id, Subject)
{:ok, bob} = %User{name: "Bob"} |> Repo.insert()
:ok = Auth.add_child(admins.id, bob.id, Subject)
:ok = Auth.add_child(marketing.id, bob.id, Subject)
{:ok, jane} = %User{name: "Jane"} |> Repo.insert()
:ok = Auth.add_child(marketing.id, jane.id, Subject)
{:ok, amanda} = %User{name: "Amanda"} |> Repo.insert()
:ok = Auth.add_child(finance.id, amanda.id, Subject)
:ok = Auth.add_child(customers.id, amanda.id, Subject)
{:ok, george} = %User{name: "George"} |> Repo.insert()
:ok = Auth.add_child(shipping.id, george.id, Subject)
:ok = Auth.add_child(customers.id, george.id, Subject)
{:ok, beth} = %User{name: "Beth"} |> Repo.insert()
:ok = Auth.add_child(shipping.id, beth.id, Subject)
{:ok, john} = %User{name: "John"} |> Repo.insert()
:ok = Auth.add_child(customers.id, john.id, Subject)
```
"""
alias Authorizir.{AuthorizationRule, Object, Permission, Subject}
import Authorizir.ErrorHelpers, only: [errors_on: 2]
import Ecto.Query, only: [from: 2]
@callback register_subject(id :: binary(), description :: String.t()) ::
:ok | {:error, reason :: atom()}
def register_subject(repo, id, description) do
case Subject.new(id, description) |> repo.insert() do
{:ok, _subject} ->
:ok
{:error, changeset} ->
cond do
"can't be blank" in errors_on(changeset, :ext_id) ->
{:error, :id_is_required}
"can't be blank" in errors_on(changeset, :description) ->
{:error, :description_is_required}
true ->
raise "Unanticipated error while adding Subject: #{inspect(changeset)}"
end
end
end
@callback grant_permission(
subject_id :: binary(),
object_id :: binary(),
permission_id :: binary()
) :: :ok | {:error, reason :: atom()}
def grant_permission(repo, subject_id, object_id, permission_id) do
create_rule(repo, subject_id, object_id, permission_id, :+)
end
@callback revoke_permission(
subject_id :: binary(),
object_id :: binary(),
permission_id :: binary()
) :: :ok | {:error, reason :: atom()}
def revoke_permission(repo, subject_id, object_id, permission_id) do
delete_rule(repo, subject_id, object_id, permission_id, :+)
end
@callback deny_permission(
subject_id :: binary(),
object_id :: binary(),
permission_id :: binary()
) :: :ok | {:error, reason :: atom()}
def deny_permission(repo, subject_id, object_id, permission_id) do
create_rule(repo, subject_id, object_id, permission_id, :-)
end
@callback allow_permission(
subject_id :: binary(),
object_id :: binary(),
permission_id :: binary()
) :: :ok | {:error, reason :: atom()}
def allow_permission(repo, subject_id, object_id, permission_id) do
delete_rule(repo, subject_id, object_id, permission_id, :-)
end
@callback add_child(parent_id :: binary(), child_id :: binary(), type :: module()) ::
:ok | {:error, reason :: atom()}
def add_child(repo, parent_id, child_id, type) do
with {:parent, parent} when not is_nil(parent) <-
{:parent, repo.get_by(type, ext_id: parent_id)},
{:child, child} when not is_nil(child) <- {:child, repo.get_by(type, ext_id: child_id)},
{:edge_created, _edge} <- type.create_edge(parent, child) |> repo.dagex_update() do
:ok
else
{:parent, nil} -> {:error, :invalid_parent}
{:child, nil} -> {:error, :invalid_parent}
{:error, _reason} = error -> error
end
end
@callback remove_child(parent_id :: binary(), child_id :: binary(), type :: module()) ::
:ok | {:error, reason :: atom()}
def remove_child(repo, parent_id, child_id, type) do
with {:parent, parent} when not is_nil(parent) <-
{:parent, repo.get_by(type, ext_id: parent_id)},
{:child, child} when not is_nil(child) <- {:child, repo.get_by(type, ext_id: child_id)},
{:edge_removed, _edge} <- type.remove_edge(parent, child) |> repo.dagex_update() do
:ok
else
{:parent, nil} -> {:error, :invalid_parent}
{:child, nil} -> {:error, :invalid_parent}
{:error, _reason} = error -> error
end
end
@callback permission_granted?(
subject_id :: binary(),
object_id :: binary(),
permission_id :: binary()
) :: :granted | :denied | {:error, reason :: atom()}
def permission_granted?(repo, subject_id, object_id, permission_id) do
with {:sop, {:ok, subject, object, permission}} <-
{:sop, sop_nodes(repo, subject_id, object_id, permission_id)} do
cond do
authorization_rule_applies?(repo, subject, object, permission, :-) -> :denied
authorization_rule_applies?(repo, subject, object, permission, :+) -> :granted
true -> :denied
end
else
{:sop, error} -> error
end
end
defp authorization_rule_applies?(repo, subject, object, permission, :-) do
from([r, s, o] in authorization_rules_for(subject, object),
join: p in subquery(Permission.with_descendants(permission)),
on: p.id == r.permission_id,
where: r.rule_type == :-
)
|> repo.exists?()
end
defp authorization_rule_applies?(repo, subject, object, permission, :+) do
from([r, s, o] in authorization_rules_for(subject, object),
join: p in subquery(Permission.with_ancestors(permission)),
on: p.id == r.permission_id,
where: r.rule_type == :+
)
|> repo.exists?()
end
defp authorization_rules_for(subject, object) do
from(r in AuthorizationRule,
join: s in subquery(Subject.with_ancestors(subject)),
on: s.id == r.subject_id,
join: o in subquery(Object.with_ancestors(object)),
on: o.id == r.object_id
)
end
defp sop_ids(repo, subject_ext_id, object_ext_id, permission_ext_id) do
case sop_nodes(repo, subject_ext_id, object_ext_id, permission_ext_id) do
{:ok, subject, object, permission} -> {:ok, subject.id, object.id, permission.id}
result -> result
end
end
defp sop_nodes(repo, subject_ext_id, object_ext_id, permission_ext_id) do
with {:subject, %{} = subject} <-
{:subject, repo.get_by(Subject, ext_id: subject_ext_id)},
{:object, %{} = object} <-
{:object, repo.get_by(Object, ext_id: object_ext_id)},
{:permission, %{} = permission} <-
{:permission, repo.get_by(Permission, ext_id: permission_ext_id)} do
{:ok, subject, object, permission}
else
{participant, nil} -> {:error, "invalid_#{participant}" |> String.to_atom()}
end
end
defp create_rule(repo, subject_id, object_id, permission_id, rule_type) do
with {:sop, {:ok, subject_id, object_id, permission_id}} <-
{:sop, sop_ids(repo, subject_id, object_id, permission_id)},
{:existing_rule, nil} <-
{:existing_rule,
repo.get_by(AuthorizationRule,
subject_id: subject_id,
object_id: object_id,
permission_id: permission_id
)} do
case AuthorizationRule.new(subject_id, object_id, permission_id, rule_type)
|> repo.insert() do
{:ok, _rule} ->
:ok
{:error, changeset} ->
cond do
true ->
raise "Unanticipated error occured while creating Authorization Rule. #{inspect(changeset)}"
end
end
else
{:sop, error} -> error
{:existing_rule, %{rule_type: ^rule_type}} -> :ok
{:existing_rule, _rule} -> {:error, :conflicting_rule_type}
end
end
defp delete_rule(repo, subject_id, object_id, permission_id, rule_type) do
with {:sop, {:ok, subject_id, object_id, permission_id}} <-
{:sop, sop_ids(repo, subject_id, object_id, permission_id)} do
from(r in AuthorizationRule,
where:
r.subject_id == ^subject_id and r.object_id == ^object_id and
r.permission_id == ^permission_id and r.rule_type == ^rule_type
)
|> repo.delete_all()
:ok
else
{:sop, error} -> error
end
end
defmacro __using__(opts) do
repo = Keyword.fetch!(opts, :repo)
quote bind_quoted: [repo: repo] do
@authorizir_repo repo
@behaviour Authorizir
@impl Authorizir
def grant_permission(subject_id, object_id, permission_id),
do: Authorizir.grant_permission(@authorizir_repo, subject_id, object_id, permission_id)
@impl Authorizir
def revoke_permission(subject_id, object_id, permission_id),
do: Authorizir.revoke_permission(@authorizir_repo, subject_id, object_id, permission_id)
@impl Authorizir
def deny_permission(subject_id, object_id, permission_id),
do: Authorizir.deny_permission(@authorizir_repo, subject_id, object_id, permission_id)
@impl Authorizir
def allow_permission(subject_id, object_id, permission_id),
do: Authorizir.allow_permission(@authorizir_repo, subject_id, object_id, permission_id)
@impl Authorizir
def permission_granted?(subject_id, object_id, permission_id),
do: Authorizir.permission_granted?(@authorizir_repo, subject_id, object_id, permission_id)
@impl Authorizir
def add_child(parent_id, child_id, type),
do: Authorizir.add_child(@authorizir_repo, parent_id, child_id, type)
@impl Authorizir
def remove_child(parent_id, child_id, type),
do: Authorizir.remove_child(@authorizir_repo, parent_id, child_id, type)
@impl Authorizir
def register_subject(id, description),
do: Authorizir.register_subject(@authorizir_repo, id, description)
end
end
end