# 🛡 Membership 🛡
[![Coverage Status](https://coveralls.io/repos/github/mithereal/ex_membership/badge.svg?branch=main)](https://coveralls.io/github/mithereal/ex_membership?branch=main)
![CircleCI](https://img.shields.io/circleci/build/github/mithereal/ex_membership)
[![Version](https://img.shields.io/hexpm/v/ex_membership.svg?style=flat-square)](https://hex.pm/packages/ex_membership)
![GitHub](https://img.shields.io/github/license/mithereal/ex_membership)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/mithereal/ex_membership/main)
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:
```elixir
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
```bash
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](https://github.com/ueberauth/ueberauth) integration
- [ ] [absinthe](https://github.com/absinthe-graphql/absinthe) middleware
- [ ] Session plug to get current_user
## Installation
```elixir
def deps do
[
{:membership, "~> 0.5.2"}
]
end
```
```elixir
# In your config/config.exs file
config :membership, Membership.Repo,
username: "postgres",
password: "postgres",
database: "membership_dev",
hostname: "localhost"
```
```elixir
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.
```elixir
# 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
```elixir
# In schema defintion
defmodule Sample.User do
use Ecto.Schema
schema "users" do
field :username, :String
belongs_to :member, Membership.Member
timestamps()
end
end
```
```elixir
# 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.
```elixir
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
```elixir
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`
```elixir
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
```elixir
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
```elixir
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
```
### Member related features
### 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
```elixir
iex> {:ok, member} = %Membership.Member{} |> Membership.Repo.insert()
```
2. Create some features
```elixir
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()
```
3. Create plan
```elixir
iex> {:ok, plan} = Membership.Plan.build("gold", [], "Gold Package") |> Membership.Repo.insert()
```
4. Grant features to a plan
```elixir
iex> Membership.Plan.grant(plan, feature_delete)
```
5. Grant plan to a member
```elixir
iex> Membership.Member.grant(member, plan)
```
6. Grant features to a member
```elixir
iex> Membership.Member.grant(member, feature_ban)
```
```elixir
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.
```elixir
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](mailto:mithereal@gmail.com)