has_many :codes

Devise and default scope for authentication

Published  

Multi tenancy with default scope

Multi tenancy in a Rails application can be achieved in various ways, but my favourite one is using Devise and default scope for authentication as it’s easy and provides good security. Essentially, the core of this technique is to define a default scope on all the resources owned by a tenant, or account. For example, say you have a tenant model named Account which has many users. The User model could define a default scope as follows:

class User < ActiveRecord::Base
  # ...
  belongs_to :account
  default_scope { where(account_id: Account.current_id) }
  # ...
end

Do see this screencast by Ryan Bates for more details on this model of multi tenancy.

The problem with this technique is that it often gets in the way of authentication solutions like Devise, which happens to be one of the most popular ones. One common way of implementing multi tenancy with Devise is using subdomains, as suggested in Ryan’s screencast; this works well because it’s easy to determine the tenant/account by just looking up the subdomain, regardless of whether the user is signed in or not. There are cases though when you don’t want or can’t use subdomains; for example, an application that enables vanity urls with subdomains only for paid users while using standard authentication for non paid users. In such scenario your application needs to implement multi tenancy both with and without subdomains.

devise and default scope

So if you need to use the typical Devise authentication while also implementing the multi tenancy with the default scope to isolate the data belonging to each account, this combination won’t work out of the box. The reason is that the user must be already signed in, in order for Devise’s current_user to be defined, and with it – through association – the current account:

class ApplicationController < ActionController::Base
  # ...

  before_filter :authenticate_user!
  around_filter :scope_current_tenant

  private

  # ...

  def scope_current_tenant
    Account.current_id = current_user.account.id if signed_in?
    yield
  ensure
    Account.current_id = nil
  end
end

If the user is not signed in, Account.current_id cannot be set, therefore the default scope on the User model will add a condition -to all the queries concerning users- that the account_id must be nil. For example when the user is attempting to sign in, a query like the following will be generated to find the user:

SELECT `users`.* FROM `users` WHERE `users`.`account_id` IS NULL AND `users`.`email` = '[email protected]' LIMIT 1

As you can see it looks for a user with account_id not set. However, it is likely that in a multi tenancy application each user belongs to an account, therefore such a query will return no results. This means that the user cannot be found, and the authentication with Devise will fail even though a user with the given email address actually exists and the password is correct. This isn’t the only problem when using Devise together with default scope for multi tenancy without subdomains. Each Devise feature is affected:

  • authentication: the first problem you won’t miss when enabling default scope in an application that uses Devise for the authentication, is simply that you won’t be able to sign in. This is because the user cannot be found for the reasons explained earlier;
  • persistent sessions: once you get the basic authentication working, you will soon notice that the session is not persisted across pages. That is, once signed in you will need to sign in again when you change page in your application. Here the default scope gets in the way when retrieving the user using the session data;
  • password recovery: there are two problems caused by default scope to the password recovery process. First, as usual the user cannot be found when supplying a valid email address; second, when reaching the ‘change my password’ form upon following the link in the email the user receives, that form will be displayed again upon submission and the user won’t actually be able to set the new password because this form will be displayed again and again. Some investigation when I was trying to fix showed that the reason for this is that since the user cannot be found in that second step of the process (because of default scope, of course), the token will be considered invalid and the password recovery form will be rendered again with a validation error;
  • resending confirmation email: this is quite similar to the password recovery; first, user cannot be found when requesting that the confirmation instruction be sent again; second, token is considered invalid and the confirmation form is displayed again and again when reaching it by clicking the link in the email.

In order for Devise to find the user in all these cases, it is necessary that it ignore the default scope. This way the query like the one I showed earlier won’t include the condition that the account_id must be nil, and therefore the user can be found. But how to ignore the default scope? As Ryan suggests in his screencast, it’s as simple as calling unscoped before a where clause. unscoped also accepts a block, so that anything executed within the given block will ignore the default scope.

So in order to get the broken features working, it is necessary to override some methods that Devise uses to extend the User model, so that these methods use unscoped. I’ll save you some time with researching and just add here the content of a mixin that I use for this purpose:

module DeviseOverrides
  def find_for_authentication(conditions)
    unscoped { super(conditions) }
  end

  def serialize_from_session(key, salt)
    unscoped { super(key, salt) }
  end

  def send_reset_password_instructions(attributes={})
    unscoped { super(attributes) }
  end

  def reset_password_by_token(attributes={})
    unscoped { super(attributes) }
  end

  def find_recoverable_or_initialize_with_errors(required_attributes, attributes, error=:invalid)
    unscoped { super(required_attributes, attributes, error) }
  end

  def send_confirmation_instructions(attributes={})
    unscoped { super(attributes) }
  end

  def confirm_by_token(confirmation_token)
    unscoped { super(confirmation_token) }
  end
end

See the use of unscoped. Then, simply extend the User model with this mixin (which I keep in the lib directory of the app):

class User < ActiveRecord::Base
  # ...
  extend DeviseOverrides
  # ...
end

That’s it. You should now have Devise working just fine with the default scope for multi tenancy in your Rails application, without subdomains. While I was investigating these issues I was wondering, would it be a good idea to update Devise’s code so to ensure it always uses unscoped by default? In my opinion this wouldn’t affect the existing behaviour and would make this way of doing multi tenancy easier without having to override any code. What do you think? If you also know of a quicker, easier way of achieving the same result, do let me know!

© Vito Botta