Share
Embed
[RailsConf 2018] Access Denied: the missing guide to authorization in Rails
Video: https://www.youtube.com/watch?v=NVwx0DARDis
http://actionpolicy.evilmartians.io
https://github.com/palkan/action_policy
https://twitter.com/palkan_tula
Rails brings us a lot of useful tools out-of-the-box, but there are missing parts too. For example, for such essential tasks as authorization we are on our own. Even if we choose a trending OSS solution, we still have to care about the way to keep our code maintainable, efficient, and, of course, bug-less.
Working on Rails projects, I've noticed some common patterns in designing access systems as well as useful code techniques I'd like to share with you in this talk.
Vladimir Dementyev
April 17, 2018
Transcript
97 slides
-
AUTHORIZATION Official permission for something to happen, or the act
of giving someone official permission to do something https://dictionary.cambridge.org/dictionary/english/authorization -
EXAMPLE class AppController < ActionController ::Base before_action :authenticate_user! def authenticate_user!
@current_user = User.find_by( token: params[:token] ) || head :unauthorized end end authentication -
EXAMPLE class UsersController < ApplicationController def destroy item = Item.find(params[:id])
if item.owner_id == current_user.id item.destroy! head :ok end else head :forbidden end end authorization -
AUTHORIZATION cancancan rolify consul allowy walruz action_access pundit the_role trust
eaco declarative_aithorization SimonSays canable kan -
EXAMPLE class ReposController < AppController def create if current_account.available_repos.zero? head
:payment_required end # ... end end constraint check -
How to grant/revoke access? Authorization model (roles, permission, accesses) How
to verify access? Authorization layer (policies, rules) AUTHORIZATION -
User Permission Resource 1 0,* 0,* 1 @resource.permissions.create!(user: user, activity:
:read) user.can?(:read, @resource) # => is transformed to @resource.permissions.exists?(user: user, activity: :read) DISCRETIONARY (DAC) -
user.can?(:read, @resource) # => is transformed to @resource.security_level <= user.security_clearance
MANDATORY (MAC) Security clearance vs. security level -
ATTRIBUTE-BASED [ { "resource": "TestApp ::Book", "action": ["read"], "description": "Allow
owners to read their books", "effect": "allow", "conditions": [ { "equal": { "resource ::owner ::id": ["user ::id"] } } ] } ] https://github.com/TheClimateCorporation/iron_hide -
How to grant/revoke access? Authorization model (roles, permission, accesses) How
to verify access? Authorization layer (policies, rules) AUTHORIZATION How to verify access? Authorization layer (policies, rules) -
WHAT PEOPLE SAY ✅ Easy to use ✅ Simple, readable
config ✅ Great community & docs CanCanCan Pundit ✅ Pure OOP ✅ No magic ✅ Easy to test * “Authorization in Rails” survey bit.ly/rails-auth -
CANCAN class Ability include CanCan ::Ability def user_abilities can :create,
[Question, Answer] can :update, [Question, Answer], user_id: user.id can :destroy, [Question, Answer], user_id: user.id can :destroy, Attachment, attachable: { user_id: user.id } can [:vote_up, :vote_down], [Question, Answer] do |resource| resource.user_id != user.id end end end -
PUNDIT class QuestionPolicy < ApplicationPolicy def index? true end def
create? true end def update? user.admin? || (user.id == target.user_id) end alias edit? update? alias destroy? update? end -
WHAT PEOPLE SAY ❌ Framework-specific ❌ A lot of magic
CanCanCan Pundit ❌ A lot of duplication ❌ No namespaces support * “Authorization in Rails” survey bit.ly/rails-auth -
THE EVOLUTION Start with CanCan Realize that it becomes more
difficult to maintain abilities as project grows Migrate to Pundit Customize it again and again -
Pundit, re-visited Born in Hell in production Pre-release published today
ACTION POLICY https://github.com/palkan/action_policy -
class ProductsController < ApplicationController before_action :load_product, except: [:index, :new, :create]
def load_product @product = current_account.products.find(params[:id]) # auto-infer policy and rule authorize! @product # explicit rule and policy authorize! @product, to: :manage?, with: SpecialProductPolicy end end ACTION POLICY -
class ProductsController < ApplicationController def index # non-raising predicate method
if allowed_to?(:create?) @tags = current_account.tags end end end ACTION POLICY -
BOILERPLATE # With Pundit class CoursePolicy < ApplicationPolicy def show?
admin? || manager? || owner? || assigned? end def update? admin? || assigned? end def destroy? admin? || manager? || owner? end end -
BOILERPLATE # With ActionPolicy class ApplicationPolicy < ActionPolicy ::Base pre_check
:allow_admins private def allow_admins allow! if user.admin? end end -
BOILERPLATE # With ActionPolicy class CoursePolicy < ApplicationPolicy def show?
manager? || owner? || assigned? end def update? assigned? end def destroy? manager? || owner? end end -
BOILERPLATE # With ActionPolicy class UserPolicy < ApplicationPolicy skip_pre_check :allow_admins,
only: :destroy? def destroy? (admin? || !record.admin?) && manage? end end -
BOILERPLATE # With Pundit class ProductsController < ApplicationController def create
authorize Product end end -
BOILERPLATE # With ActionPolicy class ProductsController < ApplicationController def create
# target class is inferred from controller authorize! end end CONVENTION OVER CONFIGURATION -
BOILERPLATE # With ActionPolicy class RepoPolicy < ApplicationPolicy # manage?
is a default (fallback) rule default_rule :manage? def manage? user.admin? || (user.id == record.user_id) end end -
BOILERPLATE # With ActionPolicy class RepoPolicy < ApplicationPolicy # or
specify explicit aliases alias_rule :update?, :destroy?, :edit?, to: :manage? def manage? user.admin? || (user.id == record.user_id) end end -
ActiveSupport ::Notifications “action_policy.authorize” # fires “action_policy.authorize" and “action_policy.apply” events authorize!
@product # fires “action_policy.apply” event allowed_to?(:edit?, @product) “action_policy.apply” -
ActiveSupport ::Notifications “action_policy.apply” ActiveSupport ::Notifications.subscribe(“action_policy.apply") do |event, started, finished, _,
data| timing = ((finished - started) * 1000).to_i $stdout.puts "authorize ##{event[:policy]} ##{event[:rule]}={timing}ms" end -
N+1 AUTHORIZATION 14 Mar 2018 14:08:30.722241 <190>1 2018-03-14T11:08:30.349156+00:00 app web.1
- - [1bb8d912-308a-4966-b436-a407ef34bac2] Started GET "/test_account/openings/public-funnel/dashboard" for 185.13.112.107 at 2018-03-14 11:08:30 +0000 14 Mar 2018 14:08:30.722186 <190>1 2018-03-14T11:08:30.358367+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] Processing by DashboardsController#show as HTML 14 Mar 2018 14:08:30.722216 <190>1 2018-03-14T11:08:30.358391+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] Parameters: {"account_slug"=>"test_account", "opening_id"=>"public-funnel"} 14 Mar 2018 14:08:30.722174 <190>1 2018-03-14T11:08:30.368142+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.scope.hit=0ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.399348+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.scope.miss=6ms 14 Mar 2018 14:08:30.722176 <190>1 2018-03-14T11:08:30.407044+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.scope.miss=21ms 14 Mar 2018 14:08:30.722174 <190>1 2018-03-14T11:08:30.441693+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.scope.hit=0ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.471756+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=2ms 14 Mar 2018 14:08:30.722174 <190>1 2018-03-14T11:08:30.474080+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.504566+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.505893+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.507222+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.508522+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.509846+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.722175 <190>1 2018-03-14T11:08:30.511189+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.796175 <190>1 2018-03-14T11:08:30.512857+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.miss=1ms 14 Mar 2018 14:08:30.796174 <190>1 2018-03-14T11:08:30.523828+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:30.796174 <190>1 2018-03-14T11:08:30.527719+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:30.796174 <190>1 2018-03-14T11:08:30.529970+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:30.928734+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.039388+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.040101+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.137807+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.138677+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.139952+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.140577+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.141658+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.142342+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.143228+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.143837+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.144657+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.145339+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.146206+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.146852+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.147671+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.148303+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.149119+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.149849+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.150702+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.151359+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.152254+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.152879+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.153761+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.295174 <190>1 2018-03-14T11:08:31.154353+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.370174 <190>1 2018-03-14T11:08:31.155193+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.370174 <190>1 2018-03-14T11:08:31.155826+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.370174 <190>1 2018-03-14T11:08:31.156625+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 14 Mar 2018 14:08:31.370174 <190>1 2018-03-14T11:08:31.157286+00:00 app web.1 - - [1bb8d912-308a-4966-b436-a407ef34bac2] [U#6] measure#action_policy.check.hit=0ms 45 policy checks per request -
CACHE STORE # application.rb config.action_policy.cache_store = :redis_cache_store class StagePolicy <
ApplicationPolicy cache :show? def show? full_access? || user.stage_permissions.where( stage_id: record.id ).exists? end def full_access? # complex SQL end end -
ActiveSupport ::Notifications “action_policy.apply” ActiveSupport ::Notifications.subscribe("action_policy.apply") do |event, started, finished, _,
data| measurement = " #{event}. #{(data[:cached] ? "hit" : "miss")}" timing = ((finished - started) * 1000).to_i $stdout.puts "measure ##{measurement}= #{timing}ms" end -
THE SURVEY Where do you test authorization logic? % of
answers 0 17,5 35 52,5 70 requests/controllers tests unit tests (e.g. policy tests) system tests other * “Authorization in Rails” survey bit.ly/rails-auth -
CASE: TESTING CONTROLLERS 0 15 30 45 60 % of
tests Other Access -
CASE: TESTING CONTROLLERS 0 1 2 3 4 5 time
to run (minutes) Other Access -
ACTION POLICY # in controller/request specs subject { patch :update,
id: product.id } it "is authorized" do expect { subject } .to be_authorized_to(:manage?, product) .with(ProductPolicy) end -
# PORO unit tests describe StagePolicy do let(:user) { build_stubbed
:user } let(:record) { build_stubbed :stage } let(:policy) { described_class.new(record, user: user) } describe '#show?' do subject { policy.show? } it { is_expected.to be true} end end ACTION POLICY -
SCOPES class CoursePolicy < ApplicationPolicy default_scope do if user.manager? scope.where(account:
account) else scope.where(account: account).assigned(user) end end # Named scope scope :own do next target.none if user.listener? scope.where(account: account, user: user) end end -
SCOPES class CoursesController < ApplicationController def index # default scope
@courses = scoped(Course.all) # named scope @courses = scoped(Course.all, :own) end end -
NAMESPACES module Admin class ProductsController < ApplicationController def show @product
= Product.find(params[:id]) # Tries to use load Admin ::ProductPolicy first # and then fallbacks to ::ProductPolicy authorize! @product end end end -
NAMESPACES class PittsburghersController < ApplicationController def authorization_namespace return ::Penguins if
current_user.hockey_fan? return ::Pirates if current_user.baseball_fan? super end end -
I18N # in locales en: action_policy: course: take: "You are
not allowed to view this course" # in controller rescue_from ActionPolicy ::Unauthorized do |ex| p ex.message # => "You are not allowed to view this course” end -
REASONS class ApplicantPolicy < ApplicationPolicy def show? allowed_to?(:view?) && allowed_to?(:show?,
stage) end def view? user.has_permission?(:view_applicants) end end -
REASONS # in controller rescue_from ActionPolicy ::Unauthorized do |exception| #
either p exception.reasons.messages # => { stage: [:show] } # or p exception.reasons.messages # => { applicant: [:view] } end -
REASONS & I18N # in locales en: action_policy: applicant: view:
"You don’t have permission to view applicants" # in controller rescue_from ActionPolicy ::Unauthorized do |exception| p exception.reasons.full_messages # => ["You don’t have permission to view applicants"] end -
class PostUpdateAction include ActionPolicy ::Behaviour # provide authorization subject (performer)
authorize :user attr_reader :user def initialize(user) @user = user end def call(post, params) authorize! post, to: :update? post.update!(params) end end authorize! -
CONTEXTS class ApplicationPolicy < ActionPolicy ::Base authorize :account end class
ApplicationController < ActionController ::Base authorize :account, through: :current_account end