🛡 Membership 🛡

Coverage StatusCircleCIVersionGitHubGitHub last commit (branch)

Membership is toolkit for granular feature management for members. It allows you to define features such as: [:can_edit, :can_delete] on a per module basis each module has an ets backed registry with {function, permission} tuple. this allows us to have plans and roles with multiple features which members can subscribe to we then can hold each user in a registry and compare features on a function level.

Here is a small example:

defmodule Post do
use Membership, registry: :post
alias Post
alias Membership.Repo
alias Membership.Member
def create_post(id, member_id \\ 1) do
member = Repo.get(Member, member_id)
post = %Post{id: id}
permissions(member) do
has_plan(:editor)
end
as_authorized(member) do
Repo.get(Post, id) |> Repo.insert_or_update()
end
# Notice that you can use both macros or functions
case authorized? do
:ok -> Repo.get(Post, id) |> Repo.delete()
{:error, message} -> raise message
_ -> raise "Member is not authorized"
end
end
def delete_post(id, member_id \\ 1) do
member = Repo.get(Member, member_id)
member = load_and_authorize_member(member)
post = %Post{id: id}
permissions do
has_plan(:admin) # or
has_plan(:editor) # or
has_feature(:delete_posts) # or
has_feature(:delete, post) # Entity related features
calculated(fn member ->
Post.email_confirmed?(member)
end)
end
as_authorized(member) do
Repo.get(Post, id) |> Repo.delete()
end
# Notice that you can use both macros or functions
case authorized? do
:ok -> Repo.get(Post, id) |> Repo.delete()
{:error, message} -> "Raise error"
_ -> "Raise error"
end
end
end

Mix Tasks

To create the migrations in your elixir project run

mix membership.install

Features

Installation

def deps do
[
{:ex_membership, ">= 0.0.0"}
]
end
# In your config/config.exs file
config :ex_membership,
ecto_repos: [MyRepo],
ecto_repo: MyRepo,
primary_key_type: :uuid
config :ex_membership, MyRepo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "ex_membership",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10,
port: 55432
iex> mix membership.setup
iex> mix membership.components

Usage with ecto

Membership 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 member with belongs_to association within your schema.

# In your migrations add member_id field
defmodule Sample.Migrations.CreateUsersTable do
use Ecto.Migration
def change do
create table(:users) do
add :username, :string
add :member_id, references(Membership.Member.table())
timestamps()
end
create unique_index(:users, [:username])
end
end

This will allow you link any internal entity with 1-1 association to members. Please note that you need to create member on each user creation (e.g with Membership.Member.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 :member, Membership.Member
timestamps()
end
end
# In your model
defmodule Sample.Post do
use Membership, registry: :post
def delete_post(id, member_id) do
user = Sample.Repo.get(Sample.User, member_id)
load_and_authorize_member(user)
# Function allows multiple signatues of member it can
# be either:
# * %Membership.Member{}
# * %AnyStruct{member: %Membership.Member{}}
# * %AnyStruct{member_id: id} (this will perform database preload)
permissions do
has_plan(:admin) # or
has_plan(:editor) # or
has_feature(:delete_posts) # or
end
member_authorized do
Sample.Repo.get(Sample.Post, id) |> Sample.repo.delete()
end
# Notice that you can use both macros or functions
case authorized? do
:ok -> Sample.Repo.get(Sample.Post, id) |> Sample.repo.delete()
{:error, message} -> raise message
_ -> raise "Member is not authorized"
end
end
end

Membership tries to infer the member, so it is easy to pass any struct (could be for example User in your application) which has set up belongs_to association for member. If the member was already preloaded from database Membership will take it as loaded member. If you didn't do preload and just loaded User -> Repo.get(User, 1) Membership will fetch the member 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
use Membership, registry: :post
def create(id \\ 1) do
member = Sample.Repo.get(Sample.User, id)
load_and_authorize_member(member)
permissions(member) do
calculated(
member,
fn member ->
Post.confirmed_email(member)
end,
:create_calculated
)
end
end
end

We can also use DSL form of calculated keyword

defmodule Sample.Post do
use Membership, registry: :post
def create(id \\ 1) do
member = Sample.Repo.get(Sample.User, id)
load_and_authorize_member(member)
permissions(member) do
calculated(
member,
:confirmed_email,
:create_calculated
)
end
def confirmed_email(member) do
member.email_confirmed?
end
end
end

Composing calculations

When we need to member calculation based on external data we can invoke bindings to calculated/2

defmodule Sample.Post do
use Membership, registry: :post
def create(id \\ 1) do
member = Sample.Repo.get(Sample.User, id)
load_and_authorize_member(member)
post = %Post{owner_id: member.id}
permissions(member) do
calculated(member,:confirmed_email)
calculated(member, :is_owner, [post])
end
end
def confirmed_email(member) do
member.email_confirmed?
end
def is_owner(member, [post]) do
member.id == post.owner_id
end
end

To perform exclusive features such as when User is owner of post AND is in editor plan we can do so as in following example

defmodule Sample.Post do
use Membership, registry: :post
def create(member_id \\ 1) do
member = Sample.Repo.get(Sample.User, member_id)
load_and_authorize_member(member)
post = %Post{owner_id: member.id}
permissions do
has_plan(:editor)
end
member_authorized do
case is_owner(member, post) do
:ok -> {:ok, "Member is the Owner of Post"}
{:error, message} -> {:error, message}
end
end
end
def is_owner(member, post) do
load_and_authorize_member(member)
permissions do
calculated(fn p, [post] ->
p.id == post.owner_id
end)
end
authorized?
end
end

We can simplify example in this case by excluding DSL for permissions

defmodule Sample.Post do
use Membership, registry: :post
def create(id \\ 1 , member_id \\ 1) do
member = Sample.Repo.get(Sample.User, member_id)
load_and_authorize_member(member)
post = %Post{owner_id: member.id}
# We can also use has_feature?/2
if has_plan?(member, :admin) and is_owner(member, post) do
{:ok, "Member Can Modify Post"}
end
end
def is_owner(member, post) do
member.id == post.owner_id
end
end

Granting features

Let's assume we want to create new Plan - gold which is able to delete accounts inside our system. We want to have special Member who is given this plan but also he is able to have Feature for banning users.

  1. Create member
iex> {:ok, member} = %Membership.Member{} |> Membership.Repo.insert()
  1. Create some features
iex> {:ok, feature_delete} = Membership.Feature.build("delete_accounts", "Delete accounts of users") |> Membership.Repo.insert()
iex> {:ok, feature_ban} = Membership.Feature.build("ban_accounts", "Ban users") |> Membership.Repo.insert()
  1. Create plan
iex> {:ok, plan} = Membership.Plan.build("gold", [], "Gold Package") |> Membership.Repo.insert()
  1. Grant features to a plan
iex> Membership.Plan.grant(plan, feature_delete)
  1. Grant plan to a member
iex> Membership.Member.grant(member, plan)
  1. Grant features to a member
iex> Membership.Member.grant(member, feature_ban)
iex> member |> Membership.Repo.preload([:plan_memberships, :extra_features])
%Membership.Member{
features: ["ban_accounts"],
identifier: "asfdcxfdsr42424eq2",
plan_memberships: [
%Membership.Plan{
identifier: "gold"
features: ["delete_accounts"]
}
]
}

Revoking features

Same as we can grant any features to models we can also revoke them.

iex> Membership.Member.revoke(member, plan)
iex> member |> Membership.Repo.preload([:plan_memberships, :extra_features])
%Membership.Member{
features: [],
identifier: "asfdcxfdsr42424eq2",
plan_memberships: []
}
iex> Membership.Member.revoke(member, feature_ban)
iex> member |> Membership.Repo.preload([:plan_memberships, :extra_features])
%Membership.Member{
features: [],
identifier: "asfdcxfdsr42424eq2",
plan_memberships: []
}

License

MIT © Jason Clark