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
end

Every 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)
end

Add 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()
end

where_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
end

and 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
end

derive :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
end

and 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}})}
end

A "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
end

A failing assertion prints why, from explain/3.

Next: Rules, Role-based access, Sharing and relations.