Skip to main content

Join GoRails to continue learning

Subscribe to GoRails to get access to this episode and all other pro episodes, and new awesome content every month.

Subscribe Now
Only $19/month

Login to your account

31 Handling Subdomains and Multitenancy From Scratch

Episode 48 · March 26, 2015

Learn how to handle subdomains in your Rails application for multi-tenant applications

Multitenancy


Resources

Notes

Add nested attributes to devise strong params by adding these lines into your ApplicationController:

class ApplicationController < ActionController::Base

  before_action :configure_permitted_parameters, if: :devise_controller?

  def configure_permitted_parameters
    devise_parameter_sanitizer.for(:sign_up) { |u|
      u.permit(:email, :password, :password_confirmation, account_attributes: [:subdomain])
    }
  end
end

Transcripts

Subscribe or login to view the transcript for this episode.

Discussion


Gravatar
Chris Zempel on

This is my favorite episode thus far. The expansion on top of Rails is so intuitive, never thought how useful separating accounts from users would be.

Hopefully there's a simple answer to this. When I'm making AJAX requests using multi-tenancy that works like this, what's the safest way to get information about the subdomain and the user into the request? Right now, my thought is to stick them directly on the page somewhere (lets say hidden fields) then pull that information out with JS to populate the rest of the form.

My worry is, somebody knows a subdomain they want to create a blog post on. So they just clack in different user_id's until they find the right combination.

Is there a different/better way to do this? Or can I somehow figure out what session the user is actually in (maybe via the csrf token?) & then validate against that before doing anything back-end? Or is this **the reason** for the csrf token, and if so, how much protection is cooked in?

This is kind of a dark area of understanding for me right now, so any insight would be appreciated!

Gravatar
Chris Oliver (167,500 XP) on

I believe you're over thinking the problem a bit around how the authorization would work. The user who is logged in cannot spoof their user because their ID is encrypted in the session. That User ID gets sent over every AJAX request so you will authorize that user's activity against the subdomain they POST'd too. Each account will need a list of users authorized to make changes on it that you authorize against. This prevents you from spoofing yourself as another user. You will basically treat them as having access to any account they are authorized on which is okay.

That said, you could modify the subdomain in the HTML, but it wouldn't matter because you would only be able to make changes on the authorized subdomains provided you set up your authorization correctly.

Gravatar
Chris Zempel on

Gotcha. Just looked into the ActionDispatch::Request generated by an AJAX POST, there's so much stuff in here.

Is this the critical info?:

session_cookie"=>{"session_id"=>"a14dbc740f50f843b0e27592a5e20971","_csrf_token"=>"i+FuZjbWJ3Uk9Ami9jrdTBpO+HBNqn2Vb1QVJbk4JVc="}

And then at a higher level, does being able to inductively answer this question and understand what generates what fall in the "rebuild rails" then "handroll user authentication" pursuit?

Gravatar
Chris Oliver (167,500 XP) on

This is the session object that you've always interacted with behind the scenes if you've ever authenticated users in an app. You may not have known it, but that's where the login information is stored to determine if a user is signed in or not. It's a cookie which gets sent over with every request therefore telling the server the status of your session being logged in or out.

The gist is: there is nothing special you would need to do for multitenant applications other than store a list of users on each account and their permissions. You can use Pundit, CanCan, etc just like you normally would but your rules would be based off the user, the account, and the record you're trying to create or modify. You would need to pass in the account as the additional option here for proper scoping and validation.

If you are to rebuild Rails, you're going to need the ability to store a cookie in the browser to login a user. The simplest version is to set a cookie called "user_id" to the database ID so you can look it up with each request just like we do here with the Account. The problem is that for the User ID, you don't want someone to be able to change it, meaning your cooking should not store the ID in plain text. To make it secure, the cookie must be encrypted so the user cannot tamper with it. This security doesn't apply to the Account, because the account is public information (by way of being in the URL).

Does that make more sense?

Gravatar
Chris Zempel on

Yeah, I'm familiar with everything you've said. I think the source of my confusion was that I thought AJAX requests weren't full http requests for some reason, so I didn't know if Rails would be able to validate sessions.

Weird, now that it's all out in the open, but this all makes much more sense. Thanks!

Gravatar
Chris Oliver (167,500 XP) on

AJAX is just a Javascript XMLHttpRequest. It's a full HTTP request, normally designed to retrieve XML but more recently JSON is the format of choice. The only real difference I believe is a header denoting it's an AJAX request.

Basically if the browser did not send over cookies during an AJAX request, those requests would be useless for any data manipulation and half the web wouldn't be able to work as designed. Including this comment form which is kinda crazy to think about.


Gravatar
Kohl Kohlbrenner on

is build_account a helper for something like User.account.build ? Is there a specific name for it/link to documentation

Gravatar
Chris Oliver (167,500 XP) on

Yeah, it's one of the methods generated by the has_one association. The docs show it as "build_association" and "create association". http://apidock.com/rails/Ac...


Gravatar
Kohl Kohlbrenner on

@excid3:disqus how would you go about eliminating subdomains like www and admin from being used? Something like a before create action that says subdomain != 'www' or 'admin' ?

Also, how does Rails know to route to pages with a subdomain? In the routes file you never explicity told rails you were using a subdomain. I feel like your code should break when you did example.lvh.me.com/users/si... and then it redirected you to the example.lvh.me.com correctly. To me, all subdomain is is an attribute on the Account model.

Gravatar
michel lamber (100 XP) on

Kohl Kohlbrenner to eliminate subdomains like www and admin I do something like this : Account.find_by(subdomain: request.subdomain) if (request.subdomain.present? && !["www", "admin"].include?(request.subdomain))

Also you don't have to tell your routes that you are using subdomains, the web server know about your url and it's enough to get the subdomain by using the "request" object which is provided by Rails.


Gravatar
Wassim Metallaoui (1,320 XP) on

How is the user_id on the account table being set?

Gravatar
Chris Oliver (167,500 XP) on

The nested form for the user also creates the account using accepts_nested_attributes_for. The user_id automatically gets set because it is created through the association.

Gravatar
Wassim Metallaoui (1,320 XP) on

I see what I was doing wrong. How would you go about making it so an account has_many users but the account.user_id gets set on the first user who made the account?

Gravatar
Chris Oliver (167,500 XP) on

You would just need to add a join table for the has_many. You can keep the account.user_id for the Owner of the account. This is probably the person you want to let invite new users, cancel the account, charge their credit card, etc. Use them as the special owner and you can keep the rest of the users in the has_many. You might want to duplicate this user in the has_many :users so that your functionality can stay the same across all the users without making too many exceptions for the owner.


Gravatar
kelvin bawa (20 XP) on

Nice tutorial. please how do i register a user with the same email address but different subdomain, like Slack?

Gravatar
Chris Oliver (167,500 XP) on

In that case, the User models would belong inside the tenants.


Gravatar
Chris Collinsworth (2,400 XP) on

Is there anything special that needs to be done to continue to use devise helpers like current_user or user_signed_in? inside of a multitenant app?

Gravatar
Chris Oliver (167,500 XP) on

Nope, that should still work out of the box. Server side will just continue to set cookies, but will only match users available for the current tenant.

Gravatar
Chris Collinsworth (2,400 XP) on

Thanks. I got it working. I don't know why it wasn't ha.

Gravatar
Chris Oliver (167,500 XP) on

Computers eh? :)

Gravatar
Chris Collinsworth (2,400 XP) on

haha yup. That's why we love 'em.


Gravatar
Omkar Lahurikar (10 XP) on

I created this Multi-tenant app, just to see how things work. But while creating new post I am keep getting error (undefined method `posts' for nil:NilClass ) I spent 3-4 hours debugging but no luck. Any help much appreciated!
Error is in
def index
@posts = @account.posts.all
end


Gravatar
ian knauer (70 XP) on

Has anyone had any luck getting this to work with rails 5? Puma seems to have some issues that i can't quite figure out. Running the server under 'rails s -p 3000 -b lvh.me' allows me to access the server correctly, but it's showing the subdomain as 'subdomain.lvh.'

Gravatar
ian knauer (70 XP) on

Somewhere in my debugging of this i had switched the action_dispatch.tld_length to be zero rather than the default of 1. Reverting back to 1 fixed the problem.


Gravatar
ian knauer (70 XP) on

Just a heads up that the code for devise_parameter_sanitizer changes a bit with Devise 4 and up. It should now be devise_parameter_sanitizer.permit(:sign_up, keys: [:email, :password, :password_confirmation, account_attributes: [:subdomain]])

Gravatar
Chris Oliver (167,500 XP) on

I forgot that changed recently, great reminder. Thanks for sharing that!

Gravatar
Jake Yeaton on

Thank you so much for this! I ran right into this error bad!


Gravatar
Travis Glover on

Is there a way to make the account available in a initializer or environment file? For instance I would like to use separate action mailer settings in environments/production.rb, and different braintree authorization settings in initializers/braintree.rb. Because the account is being set in the application controller, it is not available to be used as a conditional in these files. Is there any way to achieve this this? Maybe I can set these attributes in another file that is below the application controller?


Gravatar
Austin Rowsell (10 XP) on

I am using Rails 5 API with Devise Token Auth and Angular 2 with Angular2-Token as my front end. I keep getting an error saying "Unpermitted parameters: confirm_success_url, registration" and it fails without saving to the DB saying "422 Unprocessable Entity". I have tried adding these to permitted parameters and then get an error saying "ActiveModel::UnknownAttributeError (unknown attribute 'confirm_success_url' for User.)" and it fails with "500 Internal Server Error". I have removed "confirm_success_url, registration" from permit as I know that this is probably not the solution but am confused on how I should move forward? Sorry if this is a silly question as I am new to some of these technologies but any help moving forward would be appreciated.


Gravatar
Misel Ademi (1,160 XP) on

Is there a way to make devise user session shared across all subdomain?


Gravatar
chrickso (10 XP) on
when following the video i was getting the error:
undefined method `for' for #<Devise::ParameterSanitizer:0x007fd1715018e8>

per this stackoverflow page (https://stackoverflow.com/questions/37341967/rails-5-undefined-method-for-for-devise-on-line-devise-parameter-sanitizer), I had to update the configure_permitted_parameters method (in application_controller) to this
def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:sign_up, keys: [ :email, :password,
                                    :password_confirmation, account_attributes: [:subdomain] ])
end
and then everything worked fine. hope this helps

Login or create an account to join the conversation.