Here goes a recipe for doing multi-org in Rails that we've cooked up in Pluron while working on Acunote. We went through multiple iterations of this in our own codebase, and in this series of articles, I'll present recipes showing how we did it. We'll finish with another iteration which uses improvements introduced in Rails 1.2.
Problem
The hosted web application you are building with Rails needs to handle multiple organizations. For example, there are several organizations (Org model), each has users in it (User model). Users can log in and see current projects (Project model) in the organization and a list of tasks in each of these projects (Task model). The schema (in PostgreSQL syntax) for such a database could look like:
primary key(id));
create table Users ( id serial not null, name varchar(50) not null, org_id integer not null,
primary key(id), foreign key(org_id) references Orgs(id));
create table Projects ( id serial not null, name varchar(50) not null, org_id integer not null,
primary key(id), foreign key(org_id) references Orgs(id));
create table Tasks ( id serial not null, description text, project_id integer not null,
primary key(id), foreign key(project_id) references Projects(id));
Let's imagine now we have two organizations - Microsoft and Apple as our clients -- and our database contains following:
-- Orgs --
id | name
----+-----------
1 | Microsoft
2 | Apple
-- Users --
id | username | org_id
----+----------+--------
1 | Bill | 1
2 | Steve | 2
-- Projects --
id | description | org_id
----+------------------+--------
1 | Launch Vista | 1
2 | Launch Leopard | 2
-- Tasks --
id | description | project_id
----+----------------------------------+------------
1 | World Domination | 1
2 | World Domination in a turtleneck | 2
We need our application so that users from one organization can only see data from that organization and not others. In other words, neither Bill nor Steve should find out that both of them are working towards world domination ;)
The Imaginary Case: No organizations at all
Before we dig into the problem let's take a look at how our application would work if we had just one organization.
Here and further in the article we'll consider TaskController with the usual CRUD operations (implementation of new/create is omitted as it is similar to edit/update) and ProjectController with one list action.
So, without any explicity organizations at all TaskController would look like:
#lists projects def list @projects = Project.find(:all) end
end
class TaskController < ApplicationController
#lists tasks in the project #example: /task/list?project=1 def list @project = Project.find(params[:project]) @tasks = @project.tasks.find(:all) end
#shows the task edit form #example: /task/edit/1 def edit @task = Task.find(params[:id]) end
#updates the task from the form #example: /task/update/1 def update @task = Task.find(params[:id]) if @task.update_attributes(params[:task]) redirect_to :action => 'list', :params=> { :project => {@task.project.id} } else render :action => 'edit' end end
#destroys the task #example: /task/destroy/1 def destroy task = Task.find(params[:id]) project = task.project task.destroy redirect_to :action => 'list', :params => { :project => project.id } end
end
This looks pretty simple, doesn't it? Now let's see how adding multiple organizations complicates this code.
Solution: Recipe #1
To implement multi-org system we need to
- provide login mechanism for users;
- list only projects in the current user's organization;
- list and update only tasks for projects in the current user's organization.
Details of login mechanism are beyond the scope of this article. What's important is that after login our system stores the current user's id somewhere in the session, say session[:user_id]. While we can easily find user's organization given user_id by doing Org.find(session[:user_id]), we'll also store current user's org_id in the session[:org_id] to avoid unnecessary database lookups.
In ProjectController we now need to restrict the list of project by passing an extra condition.
Things get slightly more complicated in TaskController. TaskController.list action should check that we use allowed project and don't list tasks from projects in other organizations. If the project is not allowed then we should forbid the operation somehow, for example by raising exception. CRUD actions should not only check that a project of this task is valid but that new value of the project_id passed by the form also refers to an allowed project, i.e. one in the same organization.
def list @project = allowed_projects.find(:first, :conditions => ["project_id = ?", params[:project_id]]) forbid unless @project @tasks = @project.tasks.find(:all) end
def edit @task = Task.find(params[:id])
<span class="c1">#check whether this task belongs to a project in this organization</span>
<span class="n">forbid</span> <span class="k">unless</span> <span class="n">allowed_projects</span><span class="p">.</span><span class="nf">include?</span> <span class="vi">@task</span><span class="p">.</span><span class="nf">project</span>
end
def update @task = Task.find(params[:id])
<span class="c1">#first check whether this task belongs to a project in this organization</span>
<span class="n">forbid</span> <span class="k">unless</span> <span class="n">allowed_projects</span><span class="p">.</span><span class="nf">include?</span> <span class="vi">@task</span><span class="p">.</span><span class="nf">project</span>
<span class="c1">#next check whether the project_id passed in params[:task][:project_id] is also allowed</span>
<span class="n">forbid</span> <span class="k">unless</span> <span class="n">allowed_projects</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:task</span><span class="p">][</span><span class="ss">:project_id</span><span class="p">])</span>
<span class="k">if</span> <span class="vi">@task</span><span class="p">.</span><span class="nf">update_attributes</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:task</span><span class="p">])</span>
<span class="n">redirect_to</span> <span class="ss">:action</span> <span class="o">=></span> <span class="s1">'list'</span><span class="p">,</span> <span class="ss">:params</span><span class="o">=></span> <span class="p">{</span> <span class="ss">:project</span> <span class="o">=></span> <span class="p">{</span><span class="vi">@task</span><span class="p">.</span><span class="nf">project</span><span class="p">.</span><span class="nf">id</span><span class="p">}</span> <span class="p">}</span>
<span class="k">else</span>
<span class="n">render</span> <span class="ss">:action</span> <span class="o">=></span> <span class="s1">'edit'</span>
<span class="k">end</span>
end
def destroy task = Task.find(params[:id]) project = task.project #check whether this task belongs to a project in this organization forbid unless allowed_projects.include? project
<span class="n">task</span><span class="p">.</span><span class="nf">destroy</span>
<span class="n">redirect_to</span> <span class="ss">:action</span> <span class="o">=></span> <span class="s1">'list'</span><span class="p">,</span> <span class="ss">:params</span> <span class="o">=></span> <span class="p">{</span> <span class="ss">:project</span> <span class="o">=></span> <span class="n">project</span><span class="p">.</span><span class="nf">id</span> <span class="p">}</span>
end
private def allowed_projects Project.find(:all, :conditions => ["org_id = ?", session[:org_id]]) end
def forbid raise RuntimeError , "Security violation - the task or project outside the organization is accessed" end end
So far so good. This recipe is usable but doesn't this all look too complicated? Yes it does. Even more, while developing our application we must always think about what is allowed and what is not, add allowed_... before each find call. Things only get worse when it comes to other CRUD operations. But there's a better solution and we'll look at it in the next article.