🛡 Terminator 🛡
Terminator is toolkit for granular ability management for performers. It allows you to define granular abilities such as:
Performer -> Ability
Performer -> [Ability, Ability, ...]
Role -> [Ability, Ability, ...]
Performer -> Role -> [Ability, Ability, Ability]
Performer -> [Role -> [Ability], Role -> [Ability, ...]]
Performer -> AnyEntity -> [Ability, ...]
It tries to mimic https://en.wikipedia.org/wiki/Attribute-based_access_control and allow to define any policy which is needed.
Here is a small example:
defmodule Sample.Post
use Terminator
def delete_post(id) do
performer = Sample.Repo.get(Terminator.Performer, 1)
load_and_authorize_performer(performer)
post = %Post{id: 1}
permissions do
has_role(:admin) # or
has_role(:editor) # or
has_ability(:delete_posts) # or
has_ability(:delete, post) # Entity related abilities
calculated(fn performer ->
performer.email_confirmed?
end)
end
as_authorized do
Sample.Repo.get(Sample.Post, id) |> Sample.repo.delete()
end
# Notice that you can use both macros or functions
case is_authorized? do
:ok -> Sample.Repo.get(Sample.Post, id) |> Sample.repo.delete()
{:error, message} -> "Raise error"
_ -> "Raise error"
end
end
Features
- [x]
Performer
->[Ability]
permission schema - [x]
Role
->[Ability]
permission schema - [x]
Performer
->[Role]
->[Ability]
permission schema - [x]
Performer
->Object
->[Ability]
permission schema - [x] Computed permission in runtime
- [x] Easily readable DSL
- [ ] Absinthe middleware
- [ ] Session plug to get current_user
Installation
def deps do
[
{:terminator, "~> 0.5.2"}
]
end
# In your config/config.exs file
config :terminator, Terminator.Repo,
username: "postgres",
password: "postgres",
database: "terminator_dev",
hostname: "localhost"
iex> mix terminator.setup
Usage with ecto
Terminator is originally designed to be used with Ecto. Usually you will want to have your own table for Accounts
/Users
living in your application. To do so you can link performer with belongs_to
association within your schema.
# In your migrations add performer_id field
defmodule Sample.Migrations.CreateUsersTable do
use Ecto.Migration
def change do
create table(:users) do
add :username, :string
add :performer_id, references(Terminator.Performer.table())
timestamps()
end
create unique_index(:users, [:username])
end
end
This will allow you link any internal entity with 1-1 association to performers. Please note that you need to create performer on each user creation (e.g with Terminator.Performer.changeset/2
) and call put_assoc
inside your changeset
# In schema defintion
defmodule Sample.User do
use Ecto.Schema
schema "users" do
field :username, :String
belongs_to :performer, Terminator.Performer
timestamps()
end
end
# In your model
defmodule Sample.Post
use Terminator
def delete_post(id) do
user = Sample.Repo.get(Sample.User, 1)
load_and_authorize_performer(user)
# Function allows multiple signatues of performer it can
# be either:
# * %Terminator.Performer{}
# * %AnyStruct{performer: %Terminator.Performer{}}
# * %AnyStruct{performer_id: id} (this will perform database preload)
permissions do
has_role(:admin) # or
has_role(:editor) # or
has_ability(:delete_posts) # or
end
as_authorized do
Sample.Repo.get(Sample.Post, id) |> Sample.repo.delete()
end
# Notice that you can use both macros or functions
case is_authorized? do
:ok -> Sample.Repo.get(Sample.Post, id) |> Sample.repo.delete()
{:error, message} -> "Raise error"
_ -> "Raise error"
end
end
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.
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
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
Composing calculations
When we need to performer calculation based on external data we can invoke bindings to calculated/2
defmodule Sample.Post do
def create() do
user = Sample.Repo.get(Sample.User, 1)
post = %Post{owner_id: 1}
load_and_authorize_performer(user)
permissions do
calculated(:confirmed_email)
calculated(:is_owner, [post])
end
end
def confirmed_email(performer) do
performer.email_confirmed?
end
def is_owner(performer, [post]) do
performer.id == post.owner_id
end
end
To perform exclusive abilities such as when User is owner of post AND is in editor role
we can do so as in following example
defmodule Sample.Post do
def create() do
user = Sample.Repo.get(Sample.User, 1)
post = %Post{owner_id: 1}
load_and_authorize_performer(user)
permissions do
has_role(:editor)
end
as_authorized do
case is_owner(performer, post) do
:ok -> ...
{:error, message} -> ...
end
end
end
def is_owner(performer, post) do
load_and_authorize_performer(performer)
permissions do
calculated(fn p, [post] ->
p.id == post.owner_id
end)
end
is_authorized?
end
end
We can simplify example in this case by excluding DSL for permissions
defmodule Sample.Post do
def create() do
user = Sample.Repo.get(Sample.User, 1)
post = %Post{owner_id: 1}
# We can also use has_ability?/2
if has_role?(user, :admin) and is_owner(user, post) do
...
end
end
def is_owner(performer, post) do
performer.id == post.owner_id
end
end
Entity related abilities
Terminator allows you to grant abilities on any particular struct. Struct needs to have signature of %{__struct__: entity_name, id: entity_id}
to infer correct relations. Lets assume that we want to grant :delete
ability on particular Post
for our performer:
iex> {:ok, performer} = %Terminator.Performer{} |> Terminator.Repo.insert()
iex> post = %Post{id: 1}
iex> ability = %Ability{identifier: "delete"}
iex> Terminator.Performer.grant(performer, :delete, post)
iex> Terminator.has_ability?(performer, :delete, post)
true
defmodule Sample.Post do
def delete() do
user = Sample.Repo.get(Sample.User, 1)
post = %Post{id: 1}
load_and_authorize_performer(user)
permissions do
has_ability(:delete, post)
end
as_authorized do
:ok
end
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.
- Create performer
iex> {:ok, performer} = %Terminator.Performer{} |> Terminator.Repo.insert()
- Create some abilities
iex> {:ok, ability_delete} = Terminator.Ability.build("delete_accounts", "Delete accounts of users") |> Terminator.Repo.insert()
iex> {:ok, ability_ban} = Terminator.Ability.build("ban_accounts", "Ban users") |> Terminator.Repo.insert()
- Create role
iex> {:ok, role} = Terminator.Role.build("admin", [], "Site administrator") |> Terminator.Repo.insert()
- Grant abilities to a role
iex> Terminator.Role.grant(role, ability_delete)
- Grant role to a performer
iex> Terminator.Performer.grant(performer, role)
- Grant abilities to a performer
iex> Terminator.Performer.grant(performer, ability_ban)
iex> performer |> Terminator.Repo.preload([:roles, :abilities])
%Terminator.Performer{
abilities: [
%Terminator.Ability{
identifier: "ban_accounts"
}
]
roles: [
%Terminator.Role{
identifier: "admin"
abilities: ["delete_accounts"]
}
]
}
Revoking abilities
Same as we can grant any abilities to models we can also revoke them.
iex> Terminator.Performer.revoke(performer, role)
iex> performer |> Terminator.Repo.preload([:roles, :abilities])
%Terminator.Performer{
abilities: [
%Terminator.Ability{
identifier: "ban_accounts"
}
]
roles: []
}
iex> Terminator.Performer.revoke(performer, ability_ban)
iex> performer |> Terminator.Repo.preload([:roles, :abilities])
%Terminator.Performer{
abilities: []
roles: []
}