Skip to content

dux/egoist

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

38 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Egoist - Ruby Access Policy Library

Egoist is an ORM and framework agnostic Ruby access policy library for defining user permissions in a clean, simple, and explicit way.

Installation

to install

gem install egoist

or in Gemfile

gem 'egoist'

and to use

require 'egoist'

Overview

Egoist provides a simple answer to the question: "Can this user perform this action on this object?" It focuses on clarity and simplicity without magic or hidden behavior.

Basic Usage

The most common usage is directly on your models

class CompanyPolicy < Policy
  def read?
    model.created_by == user.id
  end
end

# if current user is defined (see "Defining Current User" section)
@company.can.read?           # returns true / false

# if you want to check for a specific user
@company.can(@user).read?    # returns true / false

# if you want to raise Policy::Error use bang method
@company.can(@user).read!    # returns @company / raises Policy::Error

# if you want to execute code when policy check fails, use a block (works for both ? and ! methods)
@company.can(@user).read? do |error_message|
  redirect_to '/', error: 'You are not allowed to access this company'
end

Complete Example

Here's a comprehensive example showcasing all features

# base model policy
class ModelPolicy < Policy
  # before filter runs before any action - if it returns true, action is allowed
  # useful for giving full permissions to admins 
  def before action
    if action != :delete?
      # access user via user or @user 
      return true if user.can.admin?
    end
  end

  def read?
    true
  end

  def update?
    # access model via model or @model
    return true if model.created_by == user.id
  end
end

class CompanyPolicy < ModelPolicy
  # @company.can.create?
  def create?
    company_count = Company.where(created_by: user.id).where('created_at>?', Time.now - 1.day).count

    if company_count > 10
      # do not allow more then 10 companies per day
      error 'You are allowed to create max 10 companies per day'
    else
      true
    end
  end

  # @company.can.read?
  def read?
    # access model via model or @model
    model.created_by == user.id
  end

  # @company.can.update?
  def update?
    # check if user is company manager
    return true if model.is_company_manager?(user)
  
    # call ModelPolicy#update?
    super
  end
end

class BlogPolicy < Policy
  COUNT = 1000

  # you can pass params to policy checks
  # @blog = Blog.new title: 'Test'
  # @blog.can.create?(request.ip)
  def create?(ip)
    if Blog.where(ip: ip).count < COUNT
      true
    else
      error "Only #{COUNT} blogs can be created per uniqe IP"
    end
  end
end

class UserPolicy < ModelPolicy
  # @user.can.update?
  def update?
    # you are not allowed to make yourself admin
    error 'You are not allowed to make yourself admin' if model.is_admin

    # you are not allowed to cahange email
    error 'Action not allowed' if model.changed?(:email)

    # you can update yourself
    return true if model.created_by == user.id

    # admins can update all users and that is handled in before filter
    # for all other useages -> not allowed
    false
  end

  # @user.can.destroy?
  def destroy?
    # users are not allowed to be destryed in any case
    false
  end

  # @user.can.admin?
  # @user.can.admin?
  def admin?
    # check if user has admin privileges
    # model is not used in this case
    user.is_admin == true
  end
end

###

class ApplicationModel
  include Policy::Model
end

@company = Company.find(123)

# full init
CompanyPolicy.new(user: @user, model: @company).can.update?

# or 
@company.can(@user).update?

# or assuming User.current == @user
@company.can.update?

Policy Check Options

@policy = SomePolicy.new(model: @some_model, user: @some_user).can
  • @policy.read?

    this will return truthy value (@model or true) or nil.

  • @policy.read!

    If you bang! method instead of question mark, Policy will raise error instead of returning nil.

  • @policy.read? { redirect_to '/' } or @policy.read! { redirect_to '/' }

    If you provide a &block, &block will be executed first and then nil will be retuned.

These are the three ways to check policies.

Key Differences from Pundit

  • exposes friendly can method for models @model.can.update?

    • you can use question mark to return boolean @model.can(@user).update? (true, false)
    • you can use bang! @model.can(@user).update!, which will raise Policy::Error error on false
    • If you want to use the current user (see "Defining Current User" section), you can use the shorthand: @model.can.update!
  • you can pass block to policy check which will be evaluated on false policy check @model.can.read? { redirect_to '/' }

  • exposes global Policy method, for easier access from where ever you need it Policy.can(model: @model, user: @user).read? (uses User.current or Current.user, can be customized)

  • In Policy classes allows before filter to be defined. If it returns true, policy is not checked

  • allows current user to be defined. Instead of @model.can(current_user).update? becomes "cleaner" @model.can.update?

  • allows customized error messages error('You are not allowed to make yourself admin') https://github.com/varvet/pundit/issues/654

  • By design, does not support Scope (ActiveRecord) pattern. Define your scopes inside models using policy checks (see "Model Scopes" section)

  • Allows passing parameters to policy checks when needed (though this should be used sparingly)

Controller Authorization

Authorization checks after the request is processed provide runtime policy validation. This is especially useful in admin dashboards.

  • You pass the model, user, and action to test. It always follows the same pattern: Can "this" user perform "this" action on "this" model? (If you need to pass multiple objects, use a Hash as the model)

  • authorize(@model, :read?) will authorize the model action and raise Policy::Error unless authorized

  • is_authorized? will return true or false.

  • is_authorized! will raise Policy::Error unless authorized.

class BaseController
  include Policy::Controller
end

class Dashboard::PostsController < BaseController
  rescue_from Policy::Error do
    # ...
  end

  after_action do
    unless is_authorized?
      raise Policy::Error.new('You are not authorized, access forbidden') 
    end

    # or raise Policy::Error
    is_authorized!
  end

  def show
    @post = Post.find_by id: params[:id]

    authorize @post.can.write?            # can current user write @post model
    authorize DashboardPolicy.can.access? # can current user access dashboard, checked in DashboardPolicy
  end

You can also use inline checks

  @post.can(user).read? { redirect_to '/', info: 'No access for you!' }

  # or as one liner, because success returns @model
  @post = Post.find_by(id: params[:id]).can.read? do
    redirect_to '/'
  end

Writing Policy Classes

Rules

  • Policy class have to inherit from Policy
  • Policy class is calculated based on a given model
    • with @post (class Post) model given, PostPolicy class will be used
    • with @foo_bar (class Foo::Bar) model given, Foo::BarPolicy class will be used
  • Policy methods end with question mark, raise errors and return true or false (def read?)
    • if you need to raise policy named error, use error method (error 'max 10 records per hour allowed')

Defining Current User

Egoist automatically detects the current user using these strategies (in order):

  1. Thread.current[:current_user]
  2. User.current (if defined)
  3. Current.user (Rails Current attributes)

You can customize how the current user is fetched

def Policy.current_user
  Thread.current[:my_current_user]
end

# now instead of full
BlogPolicy.new(@blog, current_user).can.read?
# or simplified
BlogPolicy.can(@blog, current_user).read?

# you can write
BlogPolicy.can(@blog).can.read?
# or autload BlogPolicy via class name
Policy.can(@blog).can.read?

# or even shorter
@blog.can.read?

# we came from 
BlogPolicy.new(@blog, current_user).can.read?
# to
@blog.can.read?
# beautiful!

Model scopes

Often, you will want to list records that a particular user has access to.

With Egoist, you define scope methods in your models (not in Policy objects), keeping the authorization logic where it belongs.

Use something like this

# In your model
class Blog
  def self.editable_by user
    if Policy.can(user: user).admin?
      # no limit if it can admin, return all records
      self
    else
      # else return only records created by user
      where(created_by: user.id)
    end
  end
end

Blog.editable_by(current_user).where(...)

Headless Policies

For policies without a corresponding model (like dashboard access), you can use symbols

# app/policies/dashboard_policy.rb
class DashboardPolicy < Policy
  def access?
    user.orgs_that_user_can_manage.count > 0
  end
end
# In controllers
authorize :dashboard, :access?

# In views
<% if DashboardPolicy.can.access? %>
  <%= link_to 'Dashboard', dashboard_path %>
<% end %>

Dependencies

None - Egoist has zero external dependencies.

Development

After checking out the repo, run bundle install to install dependencies. Then, run rspec to run the tests.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/dux/egoist. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.

About

Ruby access policy library, cleaner Pundit gem alternative.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages