This guide builds the permissions of a small app, one step at a time: a
blog where people write posts, then orgs whose members share documents.
It assumes Sekisho is installed (see the README) and that your app has
Phoenix 1.8's %Scope{} from mix phx.gen.auth.
1. The permissions module
The installer wrote lib/my_app/permissions.ex:
defmodule MyApp.Permissions do
use Sekisho,
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
policies: []
@impl Sekisho
def actor(%MyApp.Accounts.Scope{user: user}), do: user
def actor(nil), do: nil
endEvery question goes through this module, with your scope first, as your
contexts take it. actor/1 turns the scope into the person asking: a
user, or nil for a visitor.
2. A rule
A policy holds the rules of one schema:
defmodule MyApp.Blog.PostPolicy do
use Sekisho.Policy, for: MyApp.Blog.Post
import Ecto.Query
alias MyApp.Accounts.User
permissions [:read, :create, :update, :delete]
@impl true
def allow(%User{id: id}, :read), do: dynamic([p], p.published or p.author_id == ^id)
def allow(%User{id: id}, permission) when permission in [:update, :delete],
do: dynamic([p], p.author_id == ^id)
def allow(%User{}, :create), do: true
def allow(nil, :read), do: dynamic([p], p.published)
endAdd it to the module:
policies: [MyApp.Blog.PostPolicy]allow/2 answers with true (everywhere), false (nowhere), or a
condition on the record. A permission no clause matches is not allowed,
so there is no last clause to write.
3. Ask
In the context:
def get_post!(scope, id) do
post = Repo.get!(Post, id)
:ok = Permissions.authorize!(scope, :read, post)
post
end
def update_post(scope, post, attrs) do
with :ok <- Permissions.authorize(scope, :update, post) do
post |> Post.changeset(attrs) |> Repo.update()
end
end
def list_posts(scope) do
Post
|> Permissions.where_allowed(scope, :read)
|> order_by(desc: :inserted_at)
|> Repo.all()
endwhere_allowed/3 takes your query and adds the condition, so it composes
with your own where, order_by and pagination. The list and the check
come from the same allow/2: a post is in the list exactly when
allowed?/3 says yes.
In a template, allowed?/3 (imported by the installer):
<.button :if={allowed?(@current_scope, :delete, @post)} phx-click="delete">Delete</.button>For a list with buttons on each row, ask once for every row:
rows = Permissions.with_permissions(posts, scope, [:update, :delete])
# [{%Post{}, [:update, :delete]}, {%Post{}, []}, ...]4. Roles
Orgs have members, and members have roles. Your app already keeps them,
in a memberships table. Tell Sekisho where they are, in a sources
module:
defmodule MyApp.Permissions.Sources do
use Sekisho.Sources
import Ecto.Query
@impl true
def roles(MyApp.Orgs.Org) do
from m in MyApp.Orgs.Membership,
select: %{actor_id: m.user_id, resource_id: m.org_id, role: m.role}
end
endand add sources: MyApp.Permissions.Sources to use Sekisho. Then the
org's policy says what each role gives:
defmodule MyApp.Orgs.OrgPolicy do
use Sekisho.Policy, for: MyApp.Orgs.Org
permissions [:read, :invite, :manage]
role :member, permissions: [:read]
role :admin, permissions: [:invite]
role :owner, permissions: :all
derive :member, from: :admin
derive :admin, from: :owner
endderive :member, from: :admin says admins are members too, so an admin
may :read and :invite.
5. Shared documents
Documents belong to an org and sit in folders. People share them with each other:
defmodule MyApp.Docs.DocumentPolicy do
use Sekisho.Policy, for: MyApp.Docs.Document
permissions [:read, :comment, :update, :share, :delete]
role :viewer, permissions: [:read]
role :commenter, permissions: [:comment]
role :editor, permissions: [:update]
role :owner, permissions: [:share, :delete]
derive :commenter, from: :editor
derive :viewer, from: :commenter
derive :editor, from: :owner
# An org's admins edit its documents.
derive :editor, from: {:org, :admin}
grant_to [:user, :link], link_up_to: :viewer
end{:org, :admin} follows the document's belongs_to :org: whoever is an
admin of that org is an editor of the document. Now share one:
{:ok, _} = Permissions.grant(scope, ana, :editor, on: document)
{:ok, %{token: token}} = Permissions.grant(scope, :link, :viewer, on: document)Granting is checked too: it takes :share, and no one gives a role above
their own. A visitor who follows the link shows its token in your scope
(link_tokens/1 of the permissions module), and may then read it.
The Sharing and relations guide adds folders, nested to any depth, and teams.
6. A LiveView
Add the hook to your live_session, after the one that sets the scope:
live_session :app,
on_mount: [
{MyAppWeb.UserAuth, :require_authenticated},
{Sekisho.LiveView, MyApp.Permissions}
] do
live "/documents/:id", DocumentLive
endand say what the page needs:
def mount(%{"id" => id}, _session, socket) do
document = Docs.get_document!(socket.assigns.current_scope, id)
{:ok,
socket
|> assign(document: document)
|> Sekisho.LiveView.watch(:read, document)
|> Sekisho.LiveView.authorize_events(%{"delete" => {:delete, :document}})}
endA "delete" event from someone who may not delete never reaches your
handle_event/3. And if Ana's access is revoked while she has the
document open, her page goes away, with a flash.
7. Test it
use Sekisho.Test, permissions: MyApp.Permissions
test "authors edit their own posts" do
assert_allowed scope(ana), :update, anas_post
refute_allowed scope(ana), :update, brunos_post
assert_where_allowed Post, scope(ana), :read, [anas_post, published_post]
end
test "the permissions are valid" do
assert MyApp.Permissions.validate!() == :ok
endA failing assertion prints why, from explain/3.
Next: Rules, Role-based access, Sharing and relations.