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

[Seattle.rb 2019] A denial!

[Seattle.rb 2019] A denial!

...Or why I’ve built yet another authorization framework?

http://actionpolicy.evilmartians.io/

Vladimir Dementyev

April 02, 2019
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. A DENIAL! A DENIAL! Vladimir Dementyev @evilmartians or why I’ve

    built yet another authorization framework?
  2. palkan_tula palkan Seattle.rb 2019 BORING DEFINITION 8 The act of

    giving someone official permission to do something https://dictionary.cambridge.org/dictionary/english/authorization
  3. palkan_tula palkan Seattle.rb 2019 BY EXAMPLE 9 class ItemsController <

    ApplicationController def destroy item = Item.find(params[:id]) if current_user.admin? || current_user.moderator? || item.owner_id == current_user.id item.destroy! head :ok end else head :forbidden end end
  4. palkan_tula palkan Seattle.rb 2019 class ItemsController < ApplicationController def destroy

    item = Item.find(params[:id]) if current_user.admin? || current_user.moderator? || item.owner_id == current_user.id item.destroy! head :ok end else head :forbidden end end authorization BY EXAMPLE 10
  5. palkan_tula palkan Seattle.rb 2019 class ItemsController < ApplicationController def destroy

    item = Item.find(params[:id]) if current_user.admin? || current_user.moderator? || item.owner_id == current_user.id item.destroy! head :ok end else head :forbidden end end Is user allowed to do that? authorization BY EXAMPLE 11
  6. palkan_tula palkan Seattle.rb 2019 AUTHORIZATION 13 How to grant/revoke access?

    Authorization model (roles, permission, accesses) How to verify access? Authorization layer (policies, rules)
  7. palkan_tula palkan Seattle.rb 2019 AUTHORIZATION 14 How to grant/revoke access?

    Authorization model (roles, permission, accesses) How to verify access? Authorization layer (policies, rules) plan for today
  8. palkan_tula palkan Seattle.rb 2019 class ItemsController < ApplicationController def destroy

    item = Item.find(params[:id]) if current_user.can?(:destroy, item) item.destroy! head :ok end else head :forbidden end end authorization layer in action BY EXAMPLE 15
  9. palkan_tula palkan Seattle.rb 2019 class ItemsController < ApplicationController def destroy

    item = Item.find(params[:id]) - if current_user.admin? || - current_user.moderator? || - item.owner_id == current_user.id + if current_user.can?(:destroy, item) item.destroy! head :ok end else head :forbidden end end BY EXAMPLE 16
  10. palkan_tula palkan Seattle.rb 2019 AUTHORIZATION 18 cancancan rolify consul allowy

    walruz action_access pundit the_role trust eaco declarative_aithorization SimonSays canable kan
  11. palkan_tula palkan Seattle.rb 2019 CANCAN(-CAN) 19 Ability class can? and

    authorize! helper authorize! :destroy, item # raises can? :destroy, item # predicate
  12. palkan_tula palkan Seattle.rb 2019 CANCAN 20 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
  13. palkan_tula palkan Seattle.rb 2019 PUNDIT 22 Policy classes are plain

    old Ruby classes authorize and policy helpers authorize item, :destroy? # raises policy(item).destroy? # predicate
  14. palkan_tula palkan Seattle.rb 2019 PUNDIT 23 class QuestionPolicy def index?

    true end def create? true end def update? user.admin? || (user.id == target.user_id) end end
  15. palkan_tula palkan Seattle.rb 2019 PUNDIT 25 You’re on your own

    with your policy classes authorize and policy helpers authorize item, :destroy? # raises policy(item).destroy? # predicate
  16. palkan_tula palkan Seattle.rb 2019 THE EVOLUTION 26 Start with CanCan

    Migrate to Pundit Customize Pundit Customize Pundit…
  17. palkan_tula palkan Seattle.rb 2019 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 33
  18. palkan_tula palkan Seattle.rb 2019 class ProductsController < ApplicationController def index

    # non-raising predicate method if allowed_to?(:create?) @tags = current_account.tags end # scoping data @products = authorized_scope(current_account.products) end end ACTION POLICY 34
  19. palkan_tula palkan Seattle.rb 2019 class ProductPolicy < ApplicationPolicy relation_scope do

    |rel| next rel if user.manager? rel.where(owner_id: user.id) end def create? user.manager? end def show? record.account_id == user.account_id end end ACTION POLICY 35
  20. palkan_tula palkan Seattle.rb 2019 BEHAVIOURS 42 Controllers (and views) Channels

    (Action Cable) GraphQL mutations and types Anything
  21. palkan_tula palkan Seattle.rb 2019 BEHAVIOUR 43 class PostUpdateAction include ActionPolicy

    ::Behaviour authorize :user attr_reader :user def initialize(user) @user = user end def call(post, params) authorize! post, to: :update? post.update!(params) end end
  22. palkan_tula palkan Seattle.rb 2019 BEHAVIOUR 44 class PostUpdateAction include ActionPolicy

    ::Behaviour authorize :user attr_reader :user def initialize(user) @user = user end def call(post, params) authorize! post, to: :update? post.update!(params) end end connect authorization layer
  23. palkan_tula palkan Seattle.rb 2019 BEHAVIOUR 45 class PostUpdateAction include ActionPolicy

    ::Behaviour authorize :user attr_reader :user def initialize(user) @user = user end def call(post, params) authorize! post, to: :update? post.update!(params) end end configure authorization context (what to pass to a policy)
  24. palkan_tula palkan Seattle.rb 2019 BEHAVIOUR 46 class PostUpdateAction include ActionPolicy

    ::Behaviour authorize :user attr_reader :user def initialize(user) @user = user end def call(post, params) authorize! post, to: :update? post.update!(params) end end apply authorization (resolve policy, resolve context, invoke rule)
  25. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 49 class ApplicationPolicy include

    ActionPolicy ::Policy ::Core end implements the API required to work with behaviours
  26. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 50 class ApplicationPolicy include

    ActionPolicy ::Policy ::Core include ActionPolicy ::Policy ::Authorization authorize :user end adds API to define the required context
  27. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 51 class ApplicationPolicy include

    ActionPolicy ::Policy ::Core include ActionPolicy ::Policy ::Authorization authorize :user end requires behaviours to provide the “user” context, and adds the attr reader
  28. palkan_tula palkan Seattle.rb 2019 BOILERPLATE 53 class RepoPolicy < ApplicationPolicy

    def update? user.admin? || (user.id == record.user_id) end alias edit? update? alias destroy? update? alias publish? update? alias unpublish? update? end
  29. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 54 class ApplicationPolicy include

    ActionPolicy ::Policy ::Core include ActionPolicy ::Policy ::Authorization include ActionPolicy ::Policy ::Aliases authorize :user end
  30. palkan_tula palkan Seattle.rb 2019 BOILERPLATE 55 class RepoPolicy < ApplicationPolicy

    # manage? is a default (fallback) rule default_rule :manage? def manage? user.admin? || (user.id == record.user_id) end end
  31. palkan_tula palkan Seattle.rb 2019 BOILERPLATE 56 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
  32. palkan_tula palkan Seattle.rb 2019 BOILERPLATE 57 class CoursePolicy < ApplicationPolicy

    def show? admin? || manager? || owner? || assigned? end def update? admin? || assigned? end def destroy? admin? || manager? || owner? end end
  33. palkan_tula palkan Seattle.rb 2019 BOILERPLATE 58 class CoursePolicy < ApplicationPolicy

    def show? admin? || manager? || owner? || assigned? end def update? admin? || assigned? end def destroy? admin? || manager? || owner? end end
  34. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 59 class ApplicationPolicy #

    ... include ActionPolicy ::Policy ::PreCheck pre_check :allow_admins def allow_admins allow! if user.admin? end end
  35. palkan_tula palkan Seattle.rb 2019 SCOPES 61 class ProductsController < ApplicationController

    def index @products = authorized_scope(current_account.products) end def create @product = Product.new( authorized_scope(params.require(:product)) ) # ... end end
  36. palkan_tula palkan Seattle.rb 2019 SCOPES 62 class ProductsController < ApplicationController

    def index @products = authorized_scope(current_account.products) end def create @product = Product.new( authorized_scope(params.require(:product) ) # … end end Use scopes to “filter” collections according to the user’s permissions
  37. palkan_tula palkan Seattle.rb 2019 SCOPES 63 class ProductsController < ApplicationController

    def index @products = authorized_scope(current_account.products) end def create @product = Product.new( authorized_scope(params.require(:product) ) # ... end end Use scopes to permit parameters
  38. palkan_tula palkan Seattle.rb 2019 ACTION POLICY SCOPES 65 General-purpose micro-framework

    Use for any kind of data (different scope types) Define matchers to automatically infer the scope type
  39. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 66 class ApplicationPolicy #

    ... include ActionPolicy ::Policy ::Scoping end add scoping API
  40. palkan_tula palkan Seattle.rb 2019 SCOPES 67 class CoursePolicy < ApplicationPolicy

    scope_for :relation do |scope| if user.manager? scope.where(account: account) else scope.where(account: account).assigned(user) end end scope_for :relation, :own do |scope| next target.none if user.listener? scope.where(account: account, user: user) end end
  41. palkan_tula palkan Seattle.rb 2019 SCOPES 68 class CoursePolicy < ApplicationPolicy

    scope_for :relation do |scope| if user.manager? scope.where(account: account) else scope.where(account: account).assigned(user) end end scope_for :relation, :own do |scope| next target.none if user.listener? scope.where(account: account, user: user) end end
  42. palkan_tula palkan Seattle.rb 2019 SCOPES 69 class CoursePolicy < ApplicationPolicy

    scope_for :relation do |scope| if user.manager? scope.where(account: account) else scope.where(account: account).assigned(user) end end scope_for :relation, :own do |scope| next target.none if user.listener? scope.where(account: account, user: user) end end
  43. palkan_tula palkan Seattle.rb 2019 SCOPE MATCHERS 70 class ApplicationPolicy scope_matcher

    :action_controller_params, ActionController ::Parameters scope_matcher :active_record_relation, ActiveRecord ::Relation scope_matcher :active_record_relation, ->(target) { target < ActiveRecord ::Base } end
  44. palkan_tula palkan Seattle.rb 2019 SCOPE MATCHERS 72 def lookup_type_from_target(target) self.class.scope_matchers.detect

    do |(_type, matcher)| matcher === target end&.first end hash of type to Module or Proc
  45. palkan_tula palkan Seattle.rb 2019 “HEAVIER THAN HEAVEN” 76 class StagePolicy

    < ApplicationPolicy def show? full_access? || user.stage_permissions.where( stage_id: record.id ).exists? end def full_access? # slow SQL query we have no time to refactor end end
  46. palkan_tula palkan Seattle.rb 2019 APPLICATION POLICY 78 class ApplicationPolicy #

    ... include ActionPolicy ::Policy ::Cache end cache the rule application result in the cache store
  47. palkan_tula palkan Seattle.rb 2019 CACHE STORE 79 # application.rb config.action_policy.cache_store

    = :redis_cache_store # or without Rails ActionPolicy.cache_store = MyCacheStore.new
  48. palkan_tula palkan Seattle.rb 2019 CACHE 80 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 use the cache store for this rule
  49. palkan_tula palkan Seattle.rb 2019 INVALIDATION 82 class Access < ApplicationRecord

    belongs_to :resource belongs_to :user after_commit :cleanup_policy_cache, on: [:create, :destroy] def cleanup_policy_cache ActionPolicy.cache_store.delete_matched( “policy_cache/ #{user_id}/“ \ “ #{ResourcePolicy.name}/ #{resource_id} /*” ) end end
  50. palkan_tula palkan Seattle.rb 2019 INVALIDATION 83 class Access < ApplicationRecord

    belongs_to :resource belongs_to :user after_commit :cleanup_policy_cache, on: [:create, :destroy] def cleanup_policy_cache ActionPolicy.cache_store.delete_matched( “policy_cache/ #{user_id}/“ \ “ #{ResourcePolicy.name}/ #{resource_id} /*” ) end end
  51. palkan_tula palkan Seattle.rb 2019 INVALIDATION 84 class Access < ApplicationRecord

    belongs_to :resource belongs_to :user after_commit :cleanup_policy_cache, on: [:create, :destroy] def cleanup_policy_cache ActionPolicy.cache_store.delete_matched( “policy_cache/ #{user_id}/“ \ “ #{ResourcePolicy.name}/ #{resource_id} /*” ) end end
  52. palkan_tula palkan Seattle.rb 2019 INVALIDATION 85 class User def policy_cache_key

    "user :: #{id} :: #{role_id}" end end class Resource def policy_cache_key " #{resource.class.name} :: #{id} :: #{access_updated_at}" end end
  53. palkan_tula palkan Seattle.rb 2019 WHAT TO TEST 87 Test that

    the required authorization has been performed
  54. palkan_tula palkan Seattle.rb 2019 BEFORE ACTION POLICY 88 describe “GET

    #index” do subject { get :index, params: {account_slug: account.slug} } include_examples "account access" include_examples "permission access", "view_applicants" end 45% of controllers test – authorization tests
  55. palkan_tula palkan Seattle.rb 2019 ACTION POLICY 89 describe “GET #index”

    do subject { get :index, params: {account_slug: account.slug} } it "is authorized" do expect { subject } .to be_authorized_to(:index?).with(ApplicantPolicy) end end
  56. palkan_tula palkan Seattle.rb 2019 ACTION POLICY 90 describe “GET #index”

    do subject { get :index, params: {account_slug: account.slug} } it "is authorized" do expect { subject } .to be_authorized_to(:index?).with(ApplicantPolicy) end end Action Policy tracks all calls to `authorize!` in test env, then checks for matching authorization (no mocks/stubs)
  57. palkan_tula palkan Seattle.rb 2019 WHAT TO TEST 91 Test that

    the required authorization has been performed Test that the required scoping has been applied
  58. palkan_tula palkan Seattle.rb 2019 ACTION POLICY 92 @posts = authorized_scope(Post.all)

    expect { subject }.to have_authorized_scope(:relation) .with_policy(PostPolicy) .with_target { |target| expect(target).to eq(Post.all) }
  59. palkan_tula palkan Seattle.rb 2019 ACTION POLICY 93 @posts = authorized_scope(Post.all)

    expect { subject }.to have_authorized_scope(:relation) .with_policy(PostPolicy) .with_target { |target| expect(target).to eq(Post.all) }
  60. palkan_tula palkan Seattle.rb 2019 ACTION POLICY MINITEST 94 include ActionPolicy

    ::TestHelper def test_authorization_performed assert_authorized_to( :index?, Applicant, with: ApplicantPolicy) do get :index, params: {account_slug: account.slug} end assert_have_authorized_scope( type: :active_record_relation, with: PostPolicy) do get :index end.with_target do |target| assert_equal Post.all, target end end
  61. palkan_tula palkan Seattle.rb 2019 WHAT TO TEST 95 Test that

    the required authorization has been performed Test that the required scoping has been applied Test the authorization rules (policy classes)
  62. palkan_tula palkan Seattle.rb 2019 ONE YEAR AGO 96 1 describe

    “#show?" do 2 let(:record) { build_stubbed(:post, :active, global: true) } 3 let(:policy) { described_class.new(post, user: user) } 4 subject { policy.apply(:show?) } 5 it “succeeds” { is_expected.to eq true } 6 context "when non-active" do 7 before { record.active = false } 8 it "fails" { is_expected.to eq false } 9 end 10 context "when not for my city" do 11 before { post.city = build_stubbed :city } 12 it "fails" { is_expected.to eq false } 13 context "when manager" do 14 let(:user) { build_stubbed(:manager) } 15 it "succeeds" { is_expected.to eq true } 16 end 17 end 18 end 19 end
  63. palkan_tula palkan Seattle.rb 2019 TODAY 97 1 describe_rule :show? do

    2 let(:record) { build_stubbed(:post, :active, global: true) } 3 succeed "when active and global" 4 failed "when non-active" do 5 before { record.active = false } 6 end 7 failed "when not for my city" do 8 before { record.city = build_stubbed :city } 9 succeed "when user is a manager" do 10 let(:user) { build_stubbed(:manager) } 11 end 12 end 13 end 13 LOC vs 19 LOC
  64. palkan_tula palkan Seattle.rb 2019 WHY DOES IT FAIL? 101 def

    rsvp? rsvp_opened? && show? && (seats_available? || rsvp_to_pack?) end
  65. palkan_tula palkan Seattle.rb 2019 WHY DOES IT FAIL? 102 def

    rsvp? binding.pry rsvp_opened? && show? && (seats_available? || rsvp_to_pack?) end
  66. palkan_tula palkan Seattle.rb 2019 WHY DOES IT FAIL? 103 pry>

    pp :rsvp? EventPolicy#rsvp? ↳ rsvp_opened? # => true AND show? # => false AND ( seats_available? # => true OR rsvp_to_pack? # => true )
  67. palkan_tula palkan Seattle.rb 2019 WHY DOES IT FAIL? 104 pry>

    pp :rsvp? EventPolicy#rsvp? ↳ rsvp_opened? # => true AND show? # => false AND ( seats_available? # => true OR rsvp_to_pack? # => true )
  68. palkan_tula palkan Seattle.rb 2019 WHY DOES IT FAIL? 105 pry>

    pp :show? EventPolicy#show? ↳ manager? # => false OR owner? # => false OR ( !record.draft? # => false AND invited? # => true )
  69. palkan_tula palkan Seattle.rb 2019 WHY DOES IT FAIL? 106 pry>

    pp :show? EventPolicy#show? ↳ manager? # => false OR owner? # => false OR ( !record.draft? # => false AND invited? # => true )
  70. palkan_tula palkan Seattle.rb 2019 HOW DOES IT WORK? 107 def

    print_method(object, method_name) ast = object.method(method_name).source.then(&Unparser.method(:parse)) # outer node is a method definition itself body = ast.children[2] Visitor.new(object).collect(body) end
  71. palkan_tula palkan Seattle.rb 2019 HOW DOES IT WORK? 108 def

    print_method(object, method_name) ast = object.method(method_name).source.then(&Unparser.method(:parse)) # outer node is a method definition itself body = ast.children[2] Visitor.new(object).collect(body) end gem “method_source” (required by “pry” or “railties”)
  72. palkan_tula palkan Seattle.rb 2019 HOW DOES IT WORK? 109 def

    print_method(object, method_name) ast = object.method(method_name).source.then(&Unparser.method(:parse)) # outer node is a method definition itself body = ast.children[2] Visitor.new(object).collect(body) end gem “unparser” (wrapper over “parser”, provides AST to code functionality)
  73. palkan_tula palkan Seattle.rb 2019 HOW DOES IT WORK? 110 def

    print_method(object, method_name) ast = object.method(method_name).source.then(&Unparser.method(:parse)) # outer node is a method definition itself body = ast.children[2] Visitor.new(object).collect(body) end
  74. palkan_tula palkan Seattle.rb 2019 REASONS 113 Provide insights on why

    authorization attempt has been rejected Generate actionable/meaningful errors
  75. palkan_tula palkan Seattle.rb 2019 REASONS 114 class ApplicantPolicy < ApplicationPolicy

    def show? user.has_permission?(:view_applicants) && allowed_to?(:show?, stage) end end
  76. palkan_tula palkan Seattle.rb 2019 REASONS 115 class ApplicantPolicy < ApplicationPolicy

    def show? user.has_permission?(:view_applicants) && allowed_to?(:show?, stage) end end Two possible rejection reasons
  77. palkan_tula palkan Seattle.rb 2019 REASONS 116 class ApplicantPolicy < ApplicationPolicy

    def show? user.has_permission?(:view_applicants) && allowed_to?(:show?, stage) end end User has no required permission
  78. palkan_tula palkan Seattle.rb 2019 REASONS 117 class ApplicantPolicy < ApplicationPolicy

    def show? user.has_permission?(:view_applicants) && allowed_to?(:show?, stage) end end User doesn’t have an access to the stage. The allowed_to? method tracks the failed checks.
  79. palkan_tula palkan Seattle.rb 2019 REASONS 118 # in controller rescue_from

    ActionPolicy ::Unauthorized do |ex| # either p exception.reasons.details # => { stage: [:show?] } # or nothing (if the first check failed) p exception.reasons.details # => {} – that’s not good end
  80. palkan_tula palkan Seattle.rb 2019 REASONS 119 class ApplicantPolicy < ApplicationPolicy

    def show? allowed_to?(:view?) && allowed_to?(:show?, stage) end def view? user.has_permission?(:view_applicants) end end
  81. palkan_tula palkan Seattle.rb 2019 REASONS 120 # in controller rescue_from

    ActionPolicy ::Unauthorized do |ex| # either p exception.reasons.details # => { stage: [:show?] } # or p exception.reasons.details # => { applicant: [:view?] } end
  82. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 122 Clients want to

    know which actions are allowed to user Clients want to show different error messages depending on the rejection reason
  83. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 123 def rsvp? check?(:no_rsvp_manager?)

    && check?(:rsvp_opened?) && show? && check?(:seats_available?) && allowed_to?(:rsvp_to_pack?) end
  84. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 124 An alias for

    allowed_to? (i.e. also tracks the failures) def rsvp? check?(:no_rsvp_manager?) && check?(:rsvp_opened?) && show? && check?(:seats_available?) && allowed_to?(:rsvp_to_pack?) end
  85. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 125 module Types class

    Event < Base include ActionPolicy ::Behaviour expose_authorization_rules :rsvp? # ... end end
  86. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 126 module Types class

    Event < Base field :can_rsvp, Types ::AuthorizationResult, null: false def can_rsvp policy = policy_for(record: object) policy.apply(rule) policy.result end end end
  87. palkan_tula palkan Seattle.rb 2019 I18N 130 en: action_policy: policy: event:

    rsvp_opened ?: "RSVP has been closed for the event" seats_available ?: "This event is sold out"
  88. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 132 def no_events_from_pack? user.

    rsvp_events. where(pack_id: record.pack_id). all?(&:grace_period_started?) || begin details[:name] = record.pack.name false end end
  89. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 133 def no_events_from_pack? user.

    rsvp_events. where(pack_id: record.pack_id). all?(&:grace_period_started?) || begin details[:name] = record.pack.name false end end Additional details metadata
  90. palkan_tula palkan Seattle.rb 2019 GRAPHQL CASE 134 en: action_policy: policy:

    event: rsvp_to_pack ?: | "You've already RSVP'd to an event “ \ “in the %{name} series, “ \ "so you cannot RSVP to this event in advance"