I am working on a multi-tenant SaaS project and was previously looking for some information on how to achieve separation of contexts. By this I mean John from Client A should not be able to view or modify data from Client B regardless of role.
What follows is my implementation of a context filter based on a clientId attribute in the User table of the application. The architecture of this app is such that I keep a clientId foreign key in each table back to the user table to preserve the link between data and client.
I created a base model (in this case, my base Admin model but the concept is the same) that all my models inherit from. While reading the manual I learned that default Scopes can be used to apply a condition to ALL Select queries. The following code alters Select queries to add a WHERE clause specifying that the clientId must equal that of the logged in user:
public function defaultScope()
/* defaultScope is used to enforce clientID (site) context. Will only pull data with that user's site ID.
* User clientID (site) is set during login process in /components/UserIdentity class
*/
{
return array(
'condition'=>"clientID='".Yii::App()->user->site."'",
);
}
Here is the relevant code from my UserIdentity class which uses setState to save the clientID:
public function authenticate()
{
$user=User::model()->findByAttributes(array('email'=>$this->username));
if ($user===null) { // No user found!
$this->errorCode=self::ERROR_USERNAME_INVALID;
} else if ($user->password !== SHA1($this->password) ) { // Invalid password!
$this->errorCode=self::ERROR_PASSWORD_INVALID;
} else { // username found, password is valid
$this->errorCode=self::ERROR_NONE;
// Store user cliendID - name it SITE:
[b]$this->setState('site', $user->clientId);[/b]
//Store identity ID
$this->_id = $user->id;
//Code to set role based on User table
switch ($user->isAdmin) {
case 0: $role = 'user'; break;
case 1: $role = 'admin'; break;
default: $role = '';
}
$this->setState('role', $role);
}
return !$this->errorCode;
}
So, a caveat. Default scope only works for SELECTs, not inserts, deletes, updates (see Guide). I can’t allow a user at clientA to create stuff for clientB. In order to prevent that from happening I am using beforeSave() in my base model. What this code does is override the clientId with the ID set at login. No matter what is submitted via a form or request this code overwrites it before the record is saved:
public function beforeSave()
/* Does not allow user to submit clientID with creation form - limits them to editing and creating data
* for own site only. Overriding whatever they submit with their clientID (site) as populated on login.
*/
{
$this->clientId = Yii::App()->user->site;
return true;
}
Finally, we need to deal with validation rules. In the base model again, I am setting clientId as a safe attribute because no matter what the user does I am using the beforeSave to override. To prevent any funky behavior I set one rule in my base model to mark the clientId as safe:
public function rules()
{
return array(
array('clientId', 'safe'),
);
}
Hope this helps someone. I’ve spent a lot of time trying to sort this out. Please let me know if you see any downside to this approach.