Skip to content

Commit

Permalink
Introduce calculated permission
Browse files Browse the repository at this point in the history
  • Loading branch information
Milos Mosovsky committed Jan 5, 2019
1 parent fd9bd9e commit d7717e6
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 9 deletions.
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ defmodule Sample.Post
has_role(:admin) # or
has_role(:editor) # or
has_ability(:delete_posts) # or
calculated(fn performer ->
performer.email_confirmed?
end)
end

as_authorized do
Expand All @@ -40,7 +43,7 @@ defmodule Sample.Post
```elixir
def deps do
[
{:terminator, "~> 0.2"}
{:terminator, "~> 0.3"}
]
end
```
Expand Down Expand Up @@ -136,6 +139,44 @@ defmodule Sample.Post

Terminator tries to infer the performer, so it is easy to pass any struct (could be for example `User` in your application) which has set up `belongs_to` association for performer. If the performer was already preloaded from database Terminator will take it as loaded performer. If you didn't do preload and just loaded `User` -> `Repo.get(User, 1)` Terminator will fetch the performer on each authorization try.

### Calculated permissions

Often you will come to case when `static` permissions are not enough. For example allow only users who confirmed their email address.

```elixir
defmodule Sample.Post do
def create() do
user = Sample.Repo.get(Sample.User, 1)
load_and_authorize_performer(user)

permissions do
calculated(fn performer -> do
performer.email_confirmed?
end)
end
end
end
```

We can also use DSL form of `calculated` keyword

```elixir
defmodule Sample.Post do
def create() do
user = Sample.Repo.get(Sample.User, 1)
load_and_authorize_performer(user)

permissions do
calculated(:confirmed_email)
end
end

def confirmed_email(performer) do
performer.email_confirmed?
end
end
```

### Granting abilities

Let's assume we want to create new `Role` - _admin_ which is able to delete accounts inside our system. We want to have special `Performer` who is given this _role_ but also he is able to have `Ability` for banning users.
Expand Down
84 changes: 78 additions & 6 deletions lib/terminator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ defmodule Terminator do
quote do
Terminator.Registry.insert(:required_abilities, [])
Terminator.Registry.insert(:required_roles, [])
Terminator.Registry.insert(:calculated_permissions, [])
unquote(block)
end
end
Expand Down Expand Up @@ -94,6 +95,72 @@ defmodule Terminator do
end
end

@doc """
Defines calculated permission to be evaluated in runtime
## Examples
defmodule HelloTest do
use Terminator
def test_authorization do
permissions do
calculated(fn performer ->
performer.email_confirmed?
end)
end
as_authorized do
IO.inspect("This code is executed only for authorized performer")
end
end
end
You can also use DSL form which takes function name as argument
defmodule HelloTest do
use Terminator
def test_authorization do
permissions do
calculated(:email_confirmed)
end
as_authorized do
IO.inspect("This code is executed only for authorized performer")
end
end
def email_confirmed(performer) do
performer.email_confirmed?
end
end
"""
defmacro calculated(func_name) when is_atom(func_name) do
quote do
{:ok, current_performer} = Terminator.Registry.lookup(:current_performer)

Terminator.Registry.add(
:calculated_permissions,
unquote(func_name)(current_performer)
)
end
end

defmacro calculated(callback) do
quote do
{:ok, current_performer} = Terminator.Registry.lookup(:current_performer)

result = apply(unquote(callback), [current_performer])

Terminator.Registry.add(
:calculated_permissions,
result
)
end
end

@doc ~S"""
Returns authorization result on collected performer and required roles/abilities
Expand All @@ -120,20 +187,23 @@ defmodule Terminator do
{:ok, current_performer} = Terminator.Registry.lookup(:current_performer)
{:ok, required_abilities} = Terminator.Registry.lookup(:required_abilities)
{:ok, required_roles} = Terminator.Registry.lookup(:required_roles)
{:ok, calculated_permissions} = Terminator.Registry.lookup(:calculated_permissions)

# If no performer is given we can assume that permissions are not granted
if is_nil(current_performer) do
{:error, "Performer is not granted to perform this action"}
else
# If no permissions were required then we can assume performe is granted
if length(required_abilities) + length(required_roles) == 0 do
if length(required_abilities) + length(required_roles) + length(calculated_permissions) == 0 do
:ok
else
# 1st layer of authorization (optimize db load)
first_layer =
authorize!([
authorize_abilities(current_performer.abilities, required_abilities)
])
authorize!(
[
authorize_abilities(current_performer.abilities, required_abilities)
] ++ calculated_permissions
)

if first_layer == :ok do
first_layer
Expand Down Expand Up @@ -168,8 +238,9 @@ defmodule Terminator do
def load_and_authorize_performer(%{performer: %Terminator.Performer{id: _id} = performer}),
do: store_performer!(performer)

def load_and_authorize_performer(%{performer_id: performer_id}),
do: load_and_store_performer!(performer_id)
def load_and_authorize_performer(%{performer_id: performer_id})
when not is_nil(performer_id),
do: load_and_store_performer!(performer_id)

def load_and_authorize_performer(performer),
do: raise(ArgumentError, message: "Invalid performer given #{inspect(performer)}")
Expand Down Expand Up @@ -240,6 +311,7 @@ defmodule Terminator do
@doc false
def authorize!(conditions) do
# Authorize empty conditions as true

conditions =
case length(conditions) do
0 -> conditions ++ [true]
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Terminator.MixProject do
use Mix.Project

@version "0.2.0"
@version "0.3.0"
def project do
[
app: :terminator,
Expand Down Expand Up @@ -90,7 +90,7 @@ defmodule Terminator.MixProject do

defp aliases do
[
test: ["ecto.drop", "ecto.create", "ecto.migrate", "test"]
test: ["ecto.create", "ecto.migrate", "test"]
]
end
end
55 changes: 55 additions & 0 deletions test/terminator/terminator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ defmodule Post do
_ -> raise ArgumentError, message: "Not authorized"
end
end

def calculated(performer, email_confirmed) do
load_and_authorize_performer(performer)

permissions do
calculated(fn _performer ->
email_confirmed
end)
end

case is_authorized?() do
:ok -> {:ok, "Authorized"}
_ -> raise ArgumentError, message: "Not authorized"
end
end

def calculated_macro(performer) do
load_and_authorize_performer(performer)

permissions do
calculated(:confirmed_email)
end

case is_authorized?() do
:ok -> {:ok, "Authorized"}
_ -> raise ArgumentError, message: "Not authorized"
end
end

def confirmed_email(_performer) do
false
end
end

defmodule Terminator.TerminatorTest do
Expand All @@ -59,6 +91,9 @@ defmodule Terminator.TerminatorTest do
functions = Post.__info__(:functions)

assert [
calculated: 2,
calculated_macro: 1,
confirmed_email: 1,
delete: 1,
load_and_authorize_performer: 1,
no_macro: 1,
Expand Down Expand Up @@ -207,4 +242,24 @@ defmodule Terminator.TerminatorTest do
assert {:ok, "Authorized"} == Post.update(user)
end
end

describe "Terminator.calculated/1" do
test "grants calculated permissions" do
performer = insert(:performer)
assert {:ok, "Authorized"} == Post.calculated(performer, true)
end

test "rejects calculated permissions" do
performer = insert(:performer)

assert_raise ArgumentError, fn ->
Post.calculated(performer, false)
end
end

test "rejects macro calculated permissions" do
performer = insert(:performer)
assert {:ok, "Authorized"} == Post.calculated(performer, true)
end
end
end

0 comments on commit d7717e6

Please sign in to comment.