Solving bizarre authorization requirements

Henning Koch, makandra GmbH

henning.koch@makandra.de
@triskweline

Authentication ⇔ Login/Logout
Authorization ⇔ Permissions
I will talk but authorization today

Scar tissue

Managers may edit bookings
Managers may edit bookings
... of their subordinates
Managers may edit bookings
... of their subordinates
... if the booking isn't older than two days
Managers may edit bookings
... of their subordinates
... if the booking isn't older than two days
... but only on workdays
Managers may edit bookings
... of their subordinates
... if the booking isn't older than two days
... but only on workdays
... and when the manager has a confirmed contract
Managers may edit bookings
... of their subordinates
... if the booking isn't older than two days
... but only on workdays
... and when the manager has a confirmed contract
... or when they're acting as a proxy for a sick manager colleague

Fighting back

Authorization anti-patterns (1)

Authorization in the model

class Post < ActiveRecord::Base

  belongs_to :author

  validate :author_is_admin

  def author_is_admin
    unless author.role == 'admin'
      errors.add(:author_id, 'must be an admin')
    end
  end

end

Authorization anti-patterns (2)

Authorization everywhere

class UsersController < ApplicationController

  def update
    user = User.find(params[:id])
    unless user.supervisor == current_user
      raise "Trying to edit unauthorized user!"
    end
    if params[:user][:role] == 'admin'
      raise "Trying to set unauthorized value!"
    end
    user.update_attributes(params[:user])
  end

end

Four rules to stay sane

1 Reduce requirements to sets of accessible things
2 Contain sets of accessible things in a central repository
3 Authorize against sets of accessible things from the repository
4 Skip authorization when not in a controller context
  1. Scopes of records the current user may see
    Lists of attributes the current user may assign
  2. Crazy authorization requirements go here.
    That repository should not be the user. Make it a "Power" or "Ability" object.
  3. Don't care how the set came to be.
  4. Stuff should work on the console.

Tools

consul

Scope-based authorization solution for Ruby on Rails.
github.com/makandra/consul

assignable_values

Enums for ActiveRecord attributes and associations.
github.com/makandra/assignable_values

... but you can do it in plain Ruby, cancan, etc.

The building blocks of authorization requirements

Restricting access to a resource

A user may only see her own posts
A user may only see her own posts
1 Reduce requirements to sets of accessible things
Post.where(:author_id => user.id)

A user may only see her own posts
2 Contain sets of accessible things in a central repository
class Power
  include Consul::Power

  def initialize(user)
    @user = user
  end

  power :posts do
    Post.where(:author_id => user.id)
  end

end

Note how we are not storing permissions with the user.

A user may only see her own posts
2 Contain sets of accessible things in a central repository
Power.current = Power.new(user)
Power.current.posts # => #<ActiveRecord::Relation>
Power.current.posts? # => true or false
Power.current.post?(post) # => true or false

A user may only see her own posts
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  def show
    @object = end_of_association_chain.find(params[:id])
  end

  def index
    @collection = end_of_association_chain.all
  end

  def end_of_association_chain
    Power.current.posts
  end

end
A user may only see her own posts
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  resource_controller

  def end_of_association_chain
    Power.current.posts
  end

end

A user may only see her own posts
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  resource_controller

  power :posts, :as => :end_of_association_chain

end

One resource, different restrictions

A user may edit her posts if they aren't published yet
A user may edit her posts if they aren't published yet
1 Reduce requirements to sets of accessible things
class Power
  # ...

  power :posts do
    Post.where(:author_id => user.id)
  end

  power :updatable_posts do
    posts.where(:published => false)
  end

end

A user may edit her posts if they aren't published yet
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  resource_controller

  def end_of_association_chain
    if action_name == 'edit' || action_name == 'update'
      Power.current.updatable_posts
    else
      Power.current.posts
    end
  end

end
A user may edit her posts if they aren't published yet
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  resource_controller

  power :crud => :posts, :as => :end_of_association_chain

end

Roles

What posts a user may see depends on her role
What posts a user may see depends on her role
1 Reduce requirements to sets of accessible things
class Power

  def initialize(user)
    @user = user
  end

  power :posts do
    case role
    when :admin then Post
    when :author then Post.where(:author_id => user.id)
    when :guest then Post.where(:open => true)
    end
  end

  private

  def role
    @user.role.to_sym
  end

end

What posts a user may see depends on her role
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  resource_controller

  power :crud => :posts, :as => :end_of_association_chain

end

Sensitive attributes

Authors may edit a post, but only admins may change its state
Authors may edit a post, but only admins may change its state
1 Reduce requirements to sets of accessible things
class Power
  # ...

  power :assignable_post_fields do
    case role
    when :admin then %w[subject body state]
    when :author then %w[subject body]
    end
  end

end
Authors may edit a post, but only admins may change its state
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController
  # ...

  def update
    @object = end_of_association_chain.find(params[:id])
    @object.update_attributes!(object_params)
    redirect_to @object
  end

  def object_params
    params[:post].slice(*Power.current.assignable_post_fields)
  end

end
Note that we're not using Rails' mass assignment protection.

Restricting attribute values

Authors may change the state of a post,
but only admins may set it to 'published'
Authors may change the state of a post,
but only admins may set it to 'published'
1 Reduce requirements to sets of accessible things
class Post < ActiveRecord::Base

  validates_inclusion_of :state,
    :in => %w[draft delivered published]

end
Authors may change the state of a post,
but only admins may set it to 'published'
1 Reduce requirements to sets of accessible things
class Post < ActiveRecord::Base

  assignable_values_for :state do
    %w[draft delivered published]
  end

end
Authors may change the state of a post,
but only admins may set it to 'published'
2 Contain sets of accessible things in a central repository
class Post < ActiveRecord::Base

  assignable_values_for :state, 
    :through => lambda { Power.current }

end

Authors may change the state of a post,
but only admins may set it to 'published'
2 Contain sets of accessible things in a central repository
class Post < ActiveRecord::Base

  authorize_values_for :state

end

Authors may change the state of a post,
but only admins may set it to 'published'
2 Contain sets of accessible things in a central repository
class Power
  # ...

  power :assignable_post_states do
    case role
    when :admin then %w[draft delivered published]
    when :author then %w[draft delivered]
    end
  end

end
Authors may change the state of a post,
but only admins may set it to 'published'
3 Authorize against sets of accessible things from the repository

The authorization rule manifests as a validation in the Post model:

post = Post.new(:state => 'published')

Power.current = Power.new(admin_user)
post.assignable_states # => ['draft', 'delivered', 'published']
post.valid? # => true

Power.current = Power.new(author_user)
post.assignable_states # => ['draft', 'delivered']
post.valid? # => false
Authors may change the state of a post,
but only admins may set it to 'published'
4 Skip authorization when not in a controller context
post = Post.new(:state => 'published')

Power.current = Power.new(author_user)
post.assignable_states # => ['draft', 'delivered']
post.valid? # => false

Power.current = nil
post.valid? # => true

Authors may change the state of a post,
but only admins may set it to 'published'
3 Authorize against sets of accessible things from the repository
class PostsController < ApplicationController

  resource_controller

  power :crud => :posts

end

Again the controller didn't need to change.

Restricting what may be associated

Users with locked accounts may not be assigned as post authors
Users with locked accounts may not be assigned as post authors
class Post < ActiveRecord::Base

  belongs_to :author
  authorize_values_for :author

end

class Power
  # ...

  power :assignable_post_authors do
    User.where(:locked => false)
  end

end

Authorized values that change with time

What may be valid today may not be valid tomorrow

What happens to a user's posts when I lock her account?

class Power
  # ...

  power :assignable_post_authors do
    User.where(:locked => false)
  end

end

Authorized values that depend on other fields

A user may change the state of all posts,
but only publish posts by their subordinates.
A user may change the state of all posts,
but only publish posts by their subordinates.
class Post < ActiveRecord::Base

  authorize_values_for :state

end

class Power
  # ...

  power :assignable_post_states do |post|
    if user.supervisor_of?(post.author)
      %w[draft pending published]
    else
      %w[draft pending]
    end
  end

end

end

Get to know makandra:
http://www.makandra.de
http://makandra.com

Talk to me afterwards or send me a message:
henning.koch@makandra.de
@triskweline

Replay this talk:
makandra.com/talks