defmodule Membership.Behaviour do
defmacro __using__(opts) do
quote do
opts = unquote(opts)
@registry Keyword.fetch!(opts, :registry)
@default_features []
@doc """
Macro for defining required permissions
## Example
defmodule HelloTest do
use Membership
def test_authorization do
permissions do
has_feature(:admin_feature, :test_authorization)
has_plan(:gold, :test_authorization)
end
end
end
"""
defmacro permissions(do: block) do
quote do
load_ets_data(unquote(@registry))
data = unquote(block)
case is_nil(data) do
true -> @default_features
false -> data
end
end
end
defmacro permissions(member, do: block) do
quote do
load_ets_data(unquote(@registry))
data = unquote(block)
case is_nil(data) do
true -> @default_features
false -> data
end
end
end
@doc """
The Function list to ignore when building the permissions registry's
"""
def ignored_functions() do
Membership.module_info()
|> Keyword.fetch!(:exports)
|> Enum.map(fn {key, _data} -> key end)
end
@doc """
Load the plans into ets for the module/functions
"""
def load_ets_data(current_module \\ @registry) do
status = Membership.Permissions.Supervisor.start(current_module)
case status do
{:error, _} ->
status
{:ok, _} ->
Map.__info__(:functions)
|> Enum.reject(fn {x, _} -> Enum.member?(ignored_functions(), x) end)
|> Enum.each(fn {x, _} ->
Membership.Permission.Server.insert(current_module, x, [])
end)
end
end
@doc """
Macro for wrapping protected code
## Example
defmodule HelloTest do
use Membership
member = HelloTest.Repo.get(Membership.Member, 1)
{:ok, member } = load_and_authorize_member(member)
def test_authorization do
as_authorized(member, :test_authorization) do
IO.inspect("This code is executed only for authorized member")
end
end
end
"""
defmacro as_authorized(member, func_name, do: block) do
quote do
with :ok <- perform_authorization!(unquote(member), unquote(func_name)) do
unquote(block)
end
end
end
@doc """
Defines calculated permission to be evaluated in runtime
## Examples
defmodule HelloTest do
use Membership
member = HelloTest.Repo.get(Membership.Member, 1)
{:ok, member } = load_and_authorize_member(member)
def test_authorization do
permissions do
calculated(fn member ->
member.email_confirmed?
end)
end
as_authorized(member) do
IO.inspect("This code is executed only for authorized member")
end
end
end
You can also use DSL form which takes function name as argument
defmodule HelloTest do
use Membership
def test_authorization do
use Membership
member = HelloTest.Repo.get(Membership.Member, 1)
{:ok, member } = load_and_authorize_member(member)
permissions do
calculated(member,:email_confirmed)
end
as_authorized(member) do
IO.inspect("This code is executed only for authorized member")
end
end
def email_confirmed(member) do
member.email_confirmed?
end
end
For more complex calculation you need to pass bindings to the function
defmodule HelloTest do
use Membership
member = HelloTest.Repo.get(Membership.Member, 1)
{:ok, member} = load_and_authorize_member(member)
def test_authorization do
post = %Post{owner_id: 1}
permissions do
calculated(member,:is_owner, [post])
calculated(fn member, [post] ->
post.owner_id == member.id
end)
end
as_authorized(member) do
IO.inspect("This code is executed only for authorized member")
end
end
def is_owner(member, [post]) do
post.owner_id == member.id
end
end
"""
defmacro calculated(current_member, func_name)
when is_atom(func_name) do
quote do
current_member = Membership.Member.Server.show(unquote(current_member))
rules = unquote(func_name)(current_member)
data = {unquote(func_name), rules}
Membership.Member.Server.add_to_calculated_registry(current_member, data)
end
end
defmacro calculated(current_member, callback, func_name) when is_atom(func_name) do
quote do
current_member =
Membership.Member.Server.show(unquote(current_member))
result = apply(unquote(callback), [current_member])
data = {unquote(func_name), [result]}
Membership.Member.Server.add_to_calculated_registry(current_member, data)
end
end
defmacro calculated(current_member, func_name, bindings) when is_atom(func_name) do
quote do
current_member =
Membership.Member.Server.show(unquote(current_member))
result = unquote(func_name)(current_member, unquote(bindings))
data = {unquote(func_name), [result]}
Membership.Member.Server.add_to_calculated_registry(current_member, data)
end
end
defmacro calculated(current_member, callback, bindings, func_name)
when is_atom(func_name) do
quote do
current_member =
Membership.Member.Server.show(unquote(current_member))
result = apply(unquote(callback), [current_member, unquote(bindings)])
data = {unquote(func_name), result}
Membership.Member.Server.add_to_calculated_registry(current_member, data)
end
end
@doc ~S"""
Returns authorization result on collected member and required features/plans
## Example
defmodule HelloTest do
use Membership
def test_authorization do
case authorized? do
:ok -> "Member is authorized"
{:error, message: _message} -> "Member is not authorized"
end
end
end
"""
@spec authorized?(Membership.Member.t(), String.t()) :: :ok | {:error, String.t()}
def authorized?(member \\ nil, func_name \\ nil) do
perform_authorization!(member, func_name)
end
@doc """
Perform authorization on passed member and plans
"""
@spec has_plan?(Membership.Member.t(), atom(), String.t()) :: boolean()
def has_plan?(%Membership.Member{} = member, func_name, plan_name) do
perform_authorization!(member, func_name, [], [Atom.to_string(plan_name)]) == :ok
end
@doc """
Perform authorization on passed member and roles
"""
@spec has_role?(Membership.Member.t(), atom(), String.t()) :: boolean()
def has_role?(%Membership.Member{} = member, func_name, role_name) do
perform_authorization!(member, func_name, [], [], [Atom.to_string(role_name)]) == :ok
end
@doc """
Requires an role within permissions block
## Example
defmodule HelloTest do
use Membership
def test_authorization do
permissions do
has_role(:gold, :test_authorization)
end
end
end
"""
@spec has_role(atom(), atom()) :: {:ok, atom()}
def has_role(role, func_name) do
case :ets.lookup(:membership_roles, role) do
[] ->
{:error, "plan: #{role} Not Found"}
{plan, features} ->
Membership.Registry.add(__MODULE__, func_name, features)
{:ok, plan}
end
end
@doc """
Perform feature check on passed member and feature
"""
def has_feature?(%Membership.Member{} = member, feature_name) do
Enum.member?(member.features, feature_name)
end
def has_feature?(%Membership.Member{} = member, func_name, feature_name) do
perform_authorization!(member, func_name, [Atom.to_string(feature_name)]) == :ok
end
@doc false
def perform_authorization!(
current_member \\ nil,
func_name \\ nil,
required_features \\ [],
required_plans \\ [],
required_roles \\ []
) do
# If no mIO.inspect(ember is given we can assume that as_authorized are not granted
if is_nil(current_member) do
{:error, "Member is not granted to perform this action"}
else
rules =
try do
fetch_rules_from_ets(func_name)
rescue
_ ->
[]
end
calculated_rules =
Membership.Member.Server.fetch_from_calculated_registry(
current_member,
func_name
)
calculated_rules =
case calculated_rules do
{_, value} ->
value
_ ->
calculated_rules
end
plan_features =
List.flatten(
Enum.map(required_plans, fn p ->
p.features
end)
)
role_features =
List.flatten(
Enum.map(required_roles, fn r ->
r.features
end)
)
required_features =
required_features ++
plan_features ++
role_features ++ rules
required_features =
case(required_features) do
nil -> []
_ -> required_features
end
# If no as_authorized were required then we can assume member is granted
if length(required_features ++ calculated_rules) == 0 do
:ok
else
reply =
authorize!(
[
authorize_features(current_member.features, required_features)
] ++
calculated_rules
)
if reply == :ok do
reply
else
{:error, "Member is not granted to perform this action"}
end
end
end
end
defp fetch_rules_from_ets(nil) do
{:error, "Unknown ETS Record for Registry #{@registry}"}
end
defp fetch_rules_from_ets(func_name) do
{:ok, value} = Membership.Registry.lookup(@registry, func_name)
value
end
@doc false
def create_membership() do
quote do
import Membership, only: [load_and_store_member!: 2]
def load_and_authorize_member(%Membership.Member{id: _id} = member),
do: load_and_store_member!(member, %{})
def load_and_authorize_member(%Membership.Member{id: _id} = member, opts),
do: load_and_store_member!(member, opts)
def load_and_authorize_member(%{member: %Membership.Member{id: _id} = member}),
do: load_and_store_member!(member, %{})
def load_and_authorize_member(%{member: %Membership.Member{id: _id} = member}, opts),
do: load_and_store_member!(member, opts)
def load_and_authorize_member(%{member_id: member_id})
when not is_nil(member_id),
do: load_and_store_member!(member_id, %{})
def load_and_authorize_member(%{member_id: member_id}, opts)
when not is_nil(member_id),
do: load_and_store_member!(member_id, opts)
def load_and_authorize_member(member),
do: raise(ArgumentError, message: "Invalid member given #{inspect(member)}")
end
end
@doc false
@spec load_member_features(Membership.Member.t()) :: Membership.Member.t()
def load_member_features(member) do
member
end
@doc false
def authorize_features(active_features \\ [], required_features \\ []) do
authorized =
Enum.filter(required_features, fn feature ->
Enum.member?(active_features, feature)
end)
length(authorized) > 0 || length(required_features) == 0
end
@doc false
def authorize!(conditions) do
# Authorize empty conditions as true
conditions =
case length(conditions) do
0 -> conditions ++ [true]
_ -> conditions
end
authorized =
!Enum.member?(conditions, false)
case authorized do
true -> :ok
_ -> {:error, "Member is not granted to perform this action"}
end
end
@doc """
Requires an plan within permissions block
## Example
defmodule HelloTest do
use Membership
def test_authorization do
permissions do
has_plan(:gold, :test_authorization)
end
end
end
"""
@spec has_plan(atom(), atom()) :: {:ok, atom()}
def has_plan(plan, func_name) do
case :ets.lookup(:membership_plans, plan) do
[] ->
{:error, "plan: #{plan} Not Found"}
{plan, features} ->
Membership.Registry.add(@registry, func_name, features)
{:ok, plan}
end
end
@doc """
Requires a feature within permissions block
## Example
defmodule HelloTest do
use Membership
def test_authorization do
permissions do
has_feature(:admin)
end
end
end
"""
@spec has_feature(atom(), atom()) :: {:ok, atom()}
def has_feature(feature, func_name) do
Membership.Registry.add(@registry, func_name, feature)
{:ok, feature}
end
end
end
end