Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[RailsConf 2018] Access Denied: the missing gui...

[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
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. 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
  2. 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
  3. 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
  4. How to grant/revoke access? Authorization model (roles, permission, accesses) How

    to verify access? Authorization layer (policies, rules) AUTHORIZATION
  5. 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)
  6. 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
  7. 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)
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. Pundit, re-visited Born in Hell in production Pre-release published today

    ACTION POLICY https://github.com/palkan/action_policy
  14. 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
  15. class ProductsController < ApplicationController def index # non-raising predicate method

    if allowed_to?(:create?) @tags = current_account.tags end end end ACTION POLICY
  16. 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
  17. BOILERPLATE # With ActionPolicy class ApplicationPolicy < ActionPolicy ::Base pre_check

    :allow_admins private def allow_admins allow! if user.admin? end end
  18. BOILERPLATE # With ActionPolicy class CoursePolicy < ApplicationPolicy def show?

    manager? || owner? || assigned? end def update? assigned? end def destroy? manager? || owner? end end
  19. BOILERPLATE # With ActionPolicy class UserPolicy < ApplicationPolicy skip_pre_check :allow_admins,

    only: :destroy? def destroy? (admin? || !record.admin?) && manage? end end
  20. BOILERPLATE # With ActionPolicy class ProductsController < ApplicationController def create

    # target class is inferred from controller authorize! end end CONVENTION OVER CONFIGURATION
  21. 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
  22. 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
  23. 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”
  24. 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
  25. 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
  26. 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
  27. 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
  28. 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
  29. CASE: TESTING CONTROLLERS 0 1 2 3 4 5 time

    to run (minutes) Other Access
  30. 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
  31. # 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
  32. 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
  33. SCOPES class CoursesController < ApplicationController def index # default scope

    @courses = scoped(Course.all) # named scope @courses = scoped(Course.all, :own) end end
  34. 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
  35. NAMESPACES class PittsburghersController < ApplicationController def authorization_namespace return ::Penguins if

    current_user.hockey_fan? return ::Pirates if current_user.baseball_fan? super end end
  36. 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
  37. REASONS class ApplicantPolicy < ApplicationPolicy def show? allowed_to?(:view?) && allowed_to?(:show?,

    stage) end def view? user.has_permission?(:view_applicants) end end
  38. REASONS # in controller rescue_from ActionPolicy ::Unauthorized do |exception| #

    either p exception.reasons.messages # => { stage: [:show] } # or p exception.reasons.messages # => { applicant: [:view] } end
  39. 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
  40. 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!
  41. CONTEXTS class ApplicationPolicy < ActionPolicy ::Base authorize :account end class

    ApplicationController < ActionController ::Base authorize :account, through: :current_account end