View Source 🛡 Membership 🛡

Coverage Status CircleCI Version GitHub GitHub last commit (branch)

Membership is toolkit for granular feature management for members. It allows you to define granular 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 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

  • [x] Member -> [Feature] permission schema
  • [x] Plan -> [Feature] permission schema
  • [x] Role -> [Feature] permission schema
  • [x] Member -> [Plan] -> [Feature] permission schema
  • [x] Member -> [Role] -> [Feature] permission schema
  • [] Member -> Object -> [Feature] permission schema
  • [x] Computed permission in runtime
  • [x] Easily readable DSL
  • [ ] ueberauth integration
  • [ ] absinthe middleware
  • [ ] Session plug to get current_user

Installation

def deps do
  [
    {:membership, "~> 0.5.2"}
  ]
end
# In your config/config.exs file
config :membership, Membership.Repo,
  username: "postgres",
  password: "postgres",
  database: "membership_dev",
  hostname: "localhost"
iex> mix membership.setup

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