A simple authorization package that uses protocols.
If available in Hex, the package can be installed
by adding bosun to your list of dependencies in mix.exs:
def deps do
[
{:bosun, "~> 1.0.1"}
]
endTo use Bosun all you need to do is define your policy by implementing the Bosun.Policy protocol for a struct. Example below:
defmodule User do
defstruct role: :guest, username: "", blocked: false
end
defmodule Post do
defstruct title: "", body: ""
end
defimpl Bosun.Policy, for: Post do
alias Bosun.Context
# Could also be defined on the fallback Any implementation
def resource_id(resource, _action, _subject, _context, _options) do
resource.id
end
# Could also be defined on the fallback Any implementation
def subject_id(_resource, _action, subject, _context, _options) do
subject.id
end
def permitted?(_resource, _action, %User{role: :admin}, context, _options) do
Context.permit(context, "Admins are allowed to do anything")
end
def permitted?(%Post{title: "A Guest Post"}, _action, %User{role: :guest}, context, _options) do
Context.permit(context, "Guests are allowed to do stuff to guest posts")
end
def permitted?(_resource, :read, %User{role: :guest}, context, _options) do
Context.permit(context, "Guests are allowed to read posts")
end
def permitted?(_resource, :comment, %User{role: :guest} = user, context, options) do
if options[:super_fan] do
Context.permit(context, "Super fans are permitted")
else
Context.deny(context, "Guests that are not super fans are not permitted")
end
|> blocked_commenter?(user)
end
def blocked_commenter?(%Context{permitted: true} = context, %User{blocked: true}) do
Context.deny(context, "User blocked from commenting")
end
def blocked_commenter?(context, _user) do
context
end
def permitted?(_resource, :update, %User{role: :guest}, context, _options) do
Context.deny(context, "User is a guest")
end
def permitted?(_resource, _action, _user, context, _options) do
context
end
endAfter defining your policy as seen above anywhere in your codebase you can call the Bosun.permit?/3 or Bosun.permit?/4 functions.
if Bosun.permit?(%User{role: :guest}, :comment, %Post{title: "Another Guest Post"}, super_fan: true) do
do_something()
else
Logger.error("Boom!!!?!")
end
case Bosun.permit(%User{role: :guest}, :comment, %Post{title: "Another Guest Post"}, super_fan: true) do
{:ok, _} -> do_something()
{:error, context} -> Logger.error(context.reason)
end
try do
Bosun.permit!(%User{role: :guest}, :comment, %Post{title: "Another Guest Post"}, super_fan: true)
do_something()
rescue
e in Impermissible -> Logger.error(e.message)
endYou can define an Any implementation as a fallback policy
defimpl Bosun.Policy, for: Any do
alias Bosun.Context
def permitted?(_resource, _action, _subject, context, _options) do
Context.deny(context, "Impermissible")
end
endPhoenix 1.8 introduces a powerful scopes feature for managing request/session context and access control. Bosun integrates seamlessly with Phoenix scopes to provide fine-grained authorization policies alongside Phoenix's automatic scope filtering.
Here's how to use Bosun with Phoenix 1.8 scopes in your LiveViews:
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
# Access the current_scope from socket assigns
scope = socket.assigns.current_scope
# Phoenix scopes automatically filter queries based on scope context
posts = Blog.list_posts(scope)
# Filter posts based on Bosun permissions - pass scope directly
viewable_posts = Enum.filter(posts, fn post ->
Bosun.permit?(scope, :read, post)
end)
{:ok, assign(socket, posts: viewable_posts)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
scope = socket.assigns.current_scope
post = Blog.get_post!(id, scope)
# Pass scope directly to Bosun
case Bosun.permit(scope, :delete, post) do
{:ok, _} ->
{:ok, _} = Blog.delete_post(post, scope)
{:noreply,
socket
|> put_flash(:info, "Post deleted successfully")
|> push_navigate(to: ~p"/posts")}
{:error, context} ->
{:noreply, put_flash(socket, :error, context.reason)}
end
end
endHere's a more complex example showing form handling with authorization:
defmodule MyAppWeb.PostLive.FormComponent do
use MyAppWeb, :live_component
@impl true
def update(%{post: post} = assigns, socket) do
scope = assigns.current_scope
changeset = Blog.change_post(post)
# Check if user can edit this post - pass scope directly
can_edit? = Bosun.permit?(scope, :update, post)
{:ok,
socket
|> assign(assigns)
|> assign(:can_edit?, can_edit?)
|> assign_form(changeset)}
end
@impl true
def handle_event("save", %{"post" => post_params}, socket) do
scope = socket.assigns.current_scope
post = socket.assigns.post
# Verify permission before saving - pass scope directly
case Bosun.permit(scope, :update, post) do
{:ok, _} ->
save_post(socket, socket.assigns.action, post_params)
{:error, context} ->
{:noreply,
socket
|> put_flash(:error, "Unauthorized: #{context.reason}")
|> push_navigate(to: socket.assigns.return_to)}
end
end
defp save_post(socket, :edit, post_params) do
scope = socket.assigns.current_scope
case Blog.update_post(socket.assigns.post, post_params, scope) do
{:ok, _post} ->
{:noreply,
socket
|> put_flash(:info, "Post updated successfully")
|> push_navigate(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
endWhen passing a Phoenix scope struct to Bosun, you'll need to implement the policy for the scope struct itself:
# Define your scope struct
defmodule MyApp.Scope do
defstruct [:current_user, :organization, :team, :permissions]
end
# Implement Bosun.Policy for the Scope struct
defimpl Bosun.Policy, for: MyApp.Scope do
alias Bosun.Context
def permitted?(%MyApp.Scope{} = scope, :update, %Post{} = post, context, _options) do
cond do
# Super admin can do anything
scope.current_user.role == :super_admin ->
Context.permit(context, "Super admin has full access")
# Organization admin in the current scope
scope.organization && admin_in_organization?(scope.current_user, scope.organization.id) ->
Context.permit(context, "Organization admin can update posts")
# Team member with write permissions in the current team scope
scope.team && has_team_permission?(scope.current_user, scope.team.id, :write) ->
Context.permit(context, "Team member with write permissions")
# Post owner
post.user_id == scope.current_user.id ->
Context.permit(context, "Post owner can update their own posts")
true ->
Context.deny(context, "Insufficient permissions")
end
end
def permitted?(%MyApp.Scope{} = scope, :delete, %Post{} = post, context, _options) do
cond do
# Only admins and post owners can delete
scope.current_user.role in [:super_admin, :admin] ->
Context.permit(context, "Admin can delete posts")
post.user_id == scope.current_user.id ->
Context.permit(context, "Post owner can delete their own posts")
true ->
Context.deny(context, "Insufficient permissions to delete")
end
end
defp admin_in_organization?(user, org_id) do
# Check if user is admin in the given organization
user.organization_roles[org_id] == :admin
end
defp has_team_permission?(user, team_id, permission) do
# Check team-level permissions
user.team_permissions[team_id] && permission in user.team_permissions[team_id]
end
endFor complex applications with nested scopes (organization, team, project):
defmodule MyAppWeb.ProjectLive.Show do
use MyAppWeb, :live_view
@impl true
def mount(%{"project_id" => project_id}, _session, socket) do
scope = socket.assigns.current_scope
# Load project with scope-aware query
project = Projects.get_project!(project_id, scope)
# Check view permission - pass scope directly
case Bosun.permit(scope, :view, project) do
{:ok, _} ->
# Load project resources based on permissions
tasks = load_permitted_tasks(project, scope)
members = load_permitted_members(project, scope)
{:ok,
socket
|> assign(:project, project)
|> assign(:tasks, tasks)
|> assign(:members, members)
|> assign(:can_edit?, Bosun.permit?(scope, :edit, project))
|> assign(:can_admin?, Bosun.permit?(scope, :admin, project))}
{:error, context} ->
{:ok,
socket
|> put_flash(:error, "Unauthorized: #{context.reason}")
|> redirect(to: ~p"/projects")}
end
end
@impl true
def handle_event("add_member", %{"user_id" => user_id}, socket) do
scope = socket.assigns.current_scope
project = socket.assigns.project
# Check if current user can add members - pass scope directly
case Bosun.permit(scope, :manage_members, project) do
{:ok, _} ->
user = Accounts.get_user!(user_id)
{:ok, _} = Projects.add_member(project, user, scope)
{:noreply,
socket
|> put_flash(:info, "Member added successfully")
|> push_patch(to: socket.assigns.live_action)}
{:error, context} ->
{:noreply, put_flash(socket, :error, context.reason)}
end
end
defp load_permitted_tasks(project, scope) do
project.tasks
|> Enum.filter(fn task ->
# Pass scope directly, with project as additional context in options
Bosun.permit?(scope, :view, task, project: project)
end)
end
defp load_permitted_members(project, scope) do
# Only show members if user has permission to view them
if Bosun.permit?(scope, :view_members, project) do
Projects.list_members(project, scope)
else
[]
end
end
endHere is the default library config.
config :bosun,
debug: trueIntegration with EventRelay
One of the unique features of Bosun is it keeps a log of why something is permitted or denied. Bosun will also send this log to EventRelay for storage and searching. Using this integration gives you an audit log for free that can be used for compliance and debugging purposes.
The event_relay_token can be obtained from EventRelay's API Key detail page for a producer API key.
config :bosun, :send_to_event_relay, true
config :bosun, :event_relay_host, "localhost"
config :bosun, :event_relay_port, "50051"
config :bosun, :event_relay_token, "..."- integrate with EventRelay
- improve documentation
Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/bosun.