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:
create table Orgs (
id serial not null,
name varchar(20) not null,
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:
class ProjectController < ApplicationController
#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.
class ProjectController < ApplicationController
def list
@projects = Project.find(:all, :conditions => ["org_id = ?", session[:org_id]])
end
end
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.
class TaskController < ApplicationController
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])
#check whether this task belongs to a project in this organization
forbid unless allowed_projects.include? @task.project
end
def update
@task = Task.find(params[:id])
#first check whether this task belongs to a project in this organization
forbid unless allowed_projects.include? @task.project
#next check whether the project_id passed in params[:task][:project_id] is also allowed
forbid unless allowed_projects.find(params[:task][:project_id])
if @task.update_attributes(params[:task])
redirect_to :action => 'list', :params=> { :project => {@task.project.id} }
else
render :action => 'edit'
end
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
task.destroy
redirect_to :action => 'list', :params => { :project => project.id }
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.