Skip to content

themusicman/bosun

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Bosun

A simple authorization package that uses protocols.

Installation

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"}
  ]
end

Basic Usage

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

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

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

Integration with Phoenix 1.8 Scopes

Phoenix 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.

Basic Phoenix LiveView Integration with Scopes

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
end

LiveView Form Example with Scopes

Here'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
end

Using Scope Context in Bosun Policies

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

Advanced: LiveView with Multi-level Scopes

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

Configuration

Here is the default library config.

config :bosun,
  debug: true

Integration 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, "..."

Todo

  • 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.

About

An authorization package with audit log for free

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published